diff --git a/.clabot b/.clabot index d75794c9..e4625a4a 100644 --- a/.clabot +++ b/.clabot @@ -1,5 +1,6 @@ { "contributors": [ + "dependabot", "CleverSource", "DarkView", "Jernik", diff --git a/backend/src/data/ApiLogins.ts b/backend/src/data/ApiLogins.ts index 5ad9c262..ae6106fc 100644 --- a/backend/src/data/ApiLogins.ts +++ b/backend/src/data/ApiLogins.ts @@ -94,7 +94,7 @@ export class ApiLogins extends BaseRepository { const login = await this.apiLogins.createQueryBuilder().where("id = :id", { id: loginId }).getOne(); if (!login || moment.utc(login.expires_at).isSameOrAfter(updatedTime)) return; - + await this.apiLogins.update( { id: loginId }, { 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/migrations/1650721020704-RemoveButtonRolesTable.ts b/backend/src/migrations/1650721020704-RemoveButtonRolesTable.ts new file mode 100644 index 00000000..ebdd0f16 --- /dev/null +++ b/backend/src/migrations/1650721020704-RemoveButtonRolesTable.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner, Table } from "typeorm"; + +export class RemoveButtonRolesTable1650721020704 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("button_roles"); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "button_roles", + columns: [ + { + name: "guild_id", + type: "bigint", + isPrimary: true, + }, + { + name: "channel_id", + type: "bigint", + isPrimary: true, + }, + { + name: "message_id", + type: "bigint", + isPrimary: true, + }, + { + name: "button_id", + type: "varchar", + length: "100", + isPrimary: true, + isUnique: true, + }, + { + name: "button_group", + type: "varchar", + length: "100", + }, + { + name: "button_name", + type: "varchar", + length: "100", + }, + ], + }), + ); + } +} diff --git a/backend/src/plugins/LocateUser/LocateUserPlugin.ts b/backend/src/plugins/LocateUser/LocateUserPlugin.ts index 506380ff..52a9b6fe 100644 --- a/backend/src/plugins/LocateUser/LocateUserPlugin.ts +++ b/backend/src/plugins/LocateUser/LocateUserPlugin.ts @@ -72,6 +72,6 @@ export const LocateUserPlugin = zeppelinGuildPlugin()({ }, beforeUnload(pluginData) { - pluginData.state.unregisterGuildEventListener(); + pluginData.state.unregisterGuildEventListener?.(); }, }); diff --git a/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts b/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts index a217c1e5..523ab7bd 100644 --- a/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts +++ b/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts @@ -1,29 +1,20 @@ import { PluginOptions } from "knub"; -import { ConfigPreprocessorFn } from "knub/dist/config/configTypes"; -import { GuildButtonRoles } from "../../data/GuildButtonRoles"; import { GuildReactionRoles } from "../../data/GuildReactionRoles"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; import { Queue } from "../../Queue"; -import { isValidSnowflake } from "../../utils"; -import { StrictValidationError } from "../../validatorUtils"; import { LogsPlugin } from "../Logs/LogsPlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { ClearReactionRolesCmd } from "./commands/ClearReactionRolesCmd"; import { InitReactionRolesCmd } from "./commands/InitReactionRolesCmd"; -import { PostButtonRolesCmd } from "./commands/PostButtonRolesCmd"; import { RefreshReactionRolesCmd } from "./commands/RefreshReactionRolesCmd"; import { AddReactionRoleEvt } from "./events/AddReactionRoleEvt"; -import { ButtonInteractionEvt } from "./events/ButtonInteractionEvt"; import { MessageDeletedEvt } from "./events/MessageDeletedEvt"; import { ConfigSchema, ReactionRolesPluginType } from "./types"; -import { autoRefreshLoop } from "./util/autoRefreshLoop"; -import { getRowCount } from "./util/splitButtonsIntoRows"; const MIN_AUTO_REFRESH = 1000 * 60 * 15; // 15min minimum, let's not abuse the API const defaultOptions: PluginOptions = { config: { - button_groups: {}, auto_refresh_interval: MIN_AUTO_REFRESH, remove_user_reactions: true, @@ -40,72 +31,12 @@ const defaultOptions: PluginOptions = { ], }; -const MAXIMUM_COMPONENT_ROWS = 5; - -const configPreprocessor: ConfigPreprocessorFn = (options) => { - if (options.config.button_groups) { - for (const [groupName, group] of Object.entries(options.config.button_groups)) { - const defaultButtonNames = Object.keys(group.default_buttons); - const defaultButtons = Object.values(group.default_buttons); - const menuNames = Object.keys(group.button_menus ?? []); - - const defaultBtnRowCount = getRowCount(defaultButtons); - if (defaultBtnRowCount > MAXIMUM_COMPONENT_ROWS || defaultBtnRowCount === 0) { - throw new StrictValidationError([ - `Invalid row count for default_buttons: You currently have ${defaultBtnRowCount}, the maximum is 5. A new row is started automatically each 5 consecutive buttons.`, - ]); - } - - for (let i = 0; i < defaultButtons.length; i++) { - const defBtn = defaultButtons[i]; - if (!menuNames.includes(defBtn.role_or_menu) && !isValidSnowflake(defBtn.role_or_menu)) { - throw new StrictValidationError([ - `Invalid value for default_buttons/${defaultButtonNames[i]}/role_or_menu: ${defBtn.role_or_menu} is neither an existing menu nor a valid snowflake.`, - ]); - } - if (!defBtn.label && !defBtn.emoji) { - throw new StrictValidationError([ - `Invalid values for default_buttons/${defaultButtonNames[i]}/(label|emoji): Must have label, emoji or both set for the button to be valid.`, - ]); - } - } - - for (const [menuName, menuButtonEntries] of Object.entries(group.button_menus ?? [])) { - const menuButtonNames = Object.keys(menuButtonEntries); - const menuButtons = Object.values(menuButtonEntries); - - const menuButtonRowCount = getRowCount(menuButtons); - if (menuButtonRowCount > MAXIMUM_COMPONENT_ROWS || menuButtonRowCount === 0) { - throw new StrictValidationError([ - `Invalid row count for button_menus/${menuName}: You currently have ${menuButtonRowCount}, the maximum is 5. A new row is started automatically each 5 consecutive buttons.`, - ]); - } - - for (let i = 0; i < menuButtons.length; i++) { - const menuBtn = menuButtons[i]; - if (!menuNames.includes(menuBtn.role_or_menu) && !isValidSnowflake(menuBtn.role_or_menu)) { - throw new StrictValidationError([ - `Invalid value for button_menus/${menuButtonNames[i]}/role_or_menu: ${menuBtn.role_or_menu} is neither an existing menu nor a valid snowflake.`, - ]); - } - if (!menuBtn.label && !menuBtn.emoji) { - throw new StrictValidationError([ - `Invalid values for default_buttons/${defaultButtonNames[i]}/(label|emoji): Must have label, emoji or both set for the button to be valid.`, - ]); - } - } - } - } - } - - return options; -}; - export const ReactionRolesPlugin = zeppelinGuildPlugin()({ name: "reaction_roles", showInDocs: true, info: { prettyName: "Reaction roles", + legacy: "Consider using the [Role buttons](/docs/plugins/role_buttons) plugin instead.", }, dependencies: () => [LogsPlugin], @@ -117,23 +48,19 @@ export const ReactionRolesPlugin = zeppelinGuildPlugin( RefreshReactionRolesCmd, ClearReactionRolesCmd, InitReactionRolesCmd, - // PostButtonRolesCmd, ], // prettier-ignore events: [ AddReactionRoleEvt, - // ButtonInteractionEvt, MessageDeletedEvt, ], - configPreprocessor, beforeLoad(pluginData) { const { state, guild } = pluginData; state.reactionRoles = GuildReactionRoles.getGuildInstance(guild.id); state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); - state.buttonRoles = GuildButtonRoles.getGuildInstance(guild.id); state.reactionRemoveQueue = new Queue(); state.roleChangeQueue = new Queue(); state.pendingRoleChanges = new Map(); diff --git a/backend/src/plugins/ReactionRoles/commands/ClearReactionRolesCmd.ts b/backend/src/plugins/ReactionRoles/commands/ClearReactionRolesCmd.ts index 3bdd6243..ff88b194 100644 --- a/backend/src/plugins/ReactionRoles/commands/ClearReactionRolesCmd.ts +++ b/backend/src/plugins/ReactionRoles/commands/ClearReactionRolesCmd.ts @@ -1,4 +1,4 @@ -import { Message, Snowflake } from "discord.js"; +import { Message } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes"; import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; import { isDiscordAPIError } from "../../../utils"; diff --git a/backend/src/plugins/ReactionRoles/commands/PostButtonRolesCmd.ts b/backend/src/plugins/ReactionRoles/commands/PostButtonRolesCmd.ts deleted file mode 100644 index c9747ed2..00000000 --- a/backend/src/plugins/ReactionRoles/commands/PostButtonRolesCmd.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { createHash } from "crypto"; -import { MessageButton, Snowflake } from "discord.js"; -import moment from "moment"; -import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; -import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { reactionRolesCmd } from "../types"; -import { splitButtonsIntoRows } from "../util/splitButtonsIntoRows"; - -export const PostButtonRolesCmd = reactionRolesCmd({ - trigger: "reaction_roles post", - permission: "can_manage", - - signature: { - channel: ct.textChannel(), - buttonGroup: ct.string(), - }, - - async run({ message: msg, args, pluginData }) { - const cfg = pluginData.config.get(); - if (!cfg.button_groups) { - sendErrorMessage(pluginData, msg.channel, "No button groups defined in config"); - return; - } - const group = cfg.button_groups[args.buttonGroup]; - - if (!group) { - sendErrorMessage(pluginData, msg.channel, `No button group matches the name **${args.buttonGroup}**`); - return; - } - - const buttons: MessageButton[] = []; - const toInsert: Array<{ customId; buttonGroup; buttonName }> = []; - for (const [buttonName, button] of Object.entries(group.default_buttons)) { - const customId = createHash("md5").update(`${buttonName}${moment.utc().valueOf()}`).digest("hex"); - - const btn = new MessageButton() - .setLabel(button.label ?? "") - .setStyle(button.style ?? "PRIMARY") - .setCustomId(customId) - .setDisabled(button.disabled ?? false); - - if (button.emoji) { - const emo = pluginData.client.emojis.resolve(button.emoji as Snowflake) ?? button.emoji; - btn.setEmoji(emo); - } - - buttons.push(btn); - toInsert.push({ customId, buttonGroup: args.buttonGroup, buttonName }); - } - const rows = splitButtonsIntoRows(buttons, Object.values(group.default_buttons)); // new MessageActionRow().addComponents(buttons); - - try { - const newMsg = await args.channel.send({ content: group.message, components: rows }); - - for (const btn of toInsert) { - await pluginData.state.buttonRoles.add( - args.channel.id, - newMsg.id, - btn.customId, - btn.buttonGroup, - btn.buttonName, - ); - } - } catch (e) { - sendErrorMessage(pluginData, msg.channel, `Error trying to post message: ${e}`); - return; - } - - await sendSuccessMessage(pluginData, msg.channel, `Successfully posted message in <#${args.channel.id}>`); - }, -}); diff --git a/backend/src/plugins/ReactionRoles/events/ButtonInteractionEvt.ts b/backend/src/plugins/ReactionRoles/events/ButtonInteractionEvt.ts deleted file mode 100644 index 65ace443..00000000 --- a/backend/src/plugins/ReactionRoles/events/ButtonInteractionEvt.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { MessageComponentInteraction } from "discord.js"; -import humanizeDuration from "humanize-duration"; -import moment from "moment"; -import { LogType } from "src/data/LogType"; -import { logger } from "src/logger"; -import { LogsPlugin } from "src/plugins/Logs/LogsPlugin"; -import { MINUTES } from "src/utils"; -import { idToTimestamp } from "src/utils/idToTimestamp"; -import { reactionRolesEvt } from "../types"; -import { handleModifyRole, handleOpenMenu } from "../util/buttonActionHandlers"; -import { BUTTON_CONTEXT_SEPARATOR, resolveStatefulCustomId } from "../util/buttonCustomIdFunctions"; -import { ButtonMenuActions } from "../util/buttonMenuActions"; - -const BUTTON_INVALIDATION_TIME = 15 * MINUTES; - -export const ButtonInteractionEvt = reactionRolesEvt({ - event: "interactionCreate", - - async listener(meta) { - const int = meta.args.interaction; - if (!int.isMessageComponent()) return; - - const cfg = meta.pluginData.config.get(); - const split = int.customId.split(BUTTON_CONTEXT_SEPARATOR); - const context = (await resolveStatefulCustomId(meta.pluginData, int.customId)) ?? { - groupName: split[0], - action: split[1], - roleOrMenu: split[2], - stateless: true, - }; - - if (context.stateless) { - if (context.roleOrMenu == null) { - // Not reaction from this plugin - return; - } - const timeSinceCreation = moment.utc().valueOf() - idToTimestamp(int.message.id)!; - if (timeSinceCreation >= BUTTON_INVALIDATION_TIME) { - sendEphemeralReply( - int, - `Sorry, but these buttons are invalid because they are older than ${humanizeDuration( - BUTTON_INVALIDATION_TIME, - )}.\nIf the menu is still available, open it again to assign yourself roles!`, - ); - return; - } - } - - const group = cfg.button_groups[context.groupName]; - if (!group) { - await sendEphemeralReply(int, `A configuration error was encountered, please contact the Administrators!`); - meta.pluginData.getPlugin(LogsPlugin).logBotAlert({ - body: `**A configuration error occurred** on buttons for message ${int.message.id}, group **${context.groupName}** not found in config`, - }); - return; - } - - // Verify that detected action is known by us - if (!(Object).values(ButtonMenuActions).includes(context.action)) { - await sendEphemeralReply(int, `A internal error was encountered, please contact the Administrators!`); - meta.pluginData.getPlugin(LogsPlugin).logBotAlert({ - body: `**A internal error occurred** on buttons for message ${int.message.id}, action **${context.action}** is not known`, - }); - return; - } - - if (context.action === ButtonMenuActions.MODIFY_ROLE) { - await handleModifyRole(meta.pluginData, int, group, context); - return; - } - - if (context.action === ButtonMenuActions.OPEN_MENU) { - await handleOpenMenu(meta.pluginData, int, group, context); - return; - } - - logger.warn( - `Action ${context.action} on button ${int.customId} (Guild: ${int.guildId}, Channel: ${int.channelId}) is unknown!`, - ); - await sendEphemeralReply(int, `A internal error was encountered, please contact the Administrators!`); - }, -}); - -async function sendEphemeralReply(interaction: MessageComponentInteraction, message: string) { - await interaction.reply({ content: message, ephemeral: true }); -} diff --git a/backend/src/plugins/ReactionRoles/events/MessageDeletedEvt.ts b/backend/src/plugins/ReactionRoles/events/MessageDeletedEvt.ts index 323da037..f75195c3 100644 --- a/backend/src/plugins/ReactionRoles/events/MessageDeletedEvt.ts +++ b/backend/src/plugins/ReactionRoles/events/MessageDeletedEvt.ts @@ -8,7 +8,6 @@ export const MessageDeletedEvt = reactionRolesEvt({ async listener(meta) { const pluginData = meta.pluginData; - await pluginData.state.buttonRoles.removeAllForMessageId(meta.args.message.id); await pluginData.state.reactionRoles.removeFromMessage(meta.args.message.id); }, }); diff --git a/backend/src/plugins/ReactionRoles/types.ts b/backend/src/plugins/ReactionRoles/types.ts index 27dc5d17..d77b71fb 100644 --- a/backend/src/plugins/ReactionRoles/types.ts +++ b/backend/src/plugins/ReactionRoles/types.ts @@ -1,40 +1,10 @@ import * as t from "io-ts"; import { BasePluginType, typedGuildCommand, typedGuildEventListener } from "knub"; -import { GuildButtonRoles } from "src/data/GuildButtonRoles"; import { GuildReactionRoles } from "../../data/GuildReactionRoles"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; import { Queue } from "../../Queue"; -import { tNullable } from "../../utils"; - -// These need to be updated every time discord adds/removes a style, -// but i cant figure out how to import MessageButtonStyles at runtime -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 ButtonOpts = t.type({ - label: tNullable(t.string), - emoji: tNullable(t.string), - role_or_menu: t.string, - style: tNullable(t.keyof(ButtonStyles)), // https://discord.js.org/#/docs/main/master/typedef/MessageButtonStyle - disabled: tNullable(t.boolean), - end_row: tNullable(t.boolean), -}); -export type TButtonOpts = t.TypeOf; - -const ButtonPairOpts = t.type({ - message: t.string, - default_buttons: t.record(t.string, ButtonOpts), - button_menus: tNullable(t.record(t.string, t.record(t.string, ButtonOpts))), -}); -export type TButtonPairOpts = t.TypeOf; export const ConfigSchema = t.type({ - button_groups: t.record(t.string, ButtonPairOpts), auto_refresh_interval: t.number, remove_user_reactions: t.boolean, can_manage: t.boolean, @@ -61,7 +31,6 @@ export interface ReactionRolesPluginType extends BasePluginType { state: { reactionRoles: GuildReactionRoles; savedMessages: GuildSavedMessages; - buttonRoles: GuildButtonRoles; reactionRemoveQueue: Queue; roleChangeQueue: Queue; diff --git a/backend/src/plugins/ReactionRoles/util/applyReactionRoleReactionsToMessage.ts b/backend/src/plugins/ReactionRoles/util/applyReactionRoleReactionsToMessage.ts index a2641bfe..4d41ba42 100644 --- a/backend/src/plugins/ReactionRoles/util/applyReactionRoleReactionsToMessage.ts +++ b/backend/src/plugins/ReactionRoles/util/applyReactionRoleReactionsToMessage.ts @@ -1,7 +1,6 @@ import { Snowflake, TextChannel } from "discord.js"; import { GuildPluginData } from "knub"; import { ReactionRole } from "../../../data/entities/ReactionRole"; -import { LogType } from "../../../data/LogType"; import { isDiscordAPIError, sleep } from "../../../utils"; import { LogsPlugin } from "../../Logs/LogsPlugin"; import { ReactionRolesPluginType } from "../types"; diff --git a/backend/src/plugins/ReactionRoles/util/buttonActionHandlers.ts b/backend/src/plugins/ReactionRoles/util/buttonActionHandlers.ts deleted file mode 100644 index 8c9884a9..00000000 --- a/backend/src/plugins/ReactionRoles/util/buttonActionHandlers.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { MessageButton, MessageComponentInteraction, Snowflake } from "discord.js"; -import { GuildPluginData } from "knub"; -import { LogType } from "../../../data/LogType"; -import { LogsPlugin } from "../../../plugins/Logs/LogsPlugin"; -import { ReactionRolesPluginType, TButtonPairOpts } from "../types"; -import { generateStatelessCustomId } from "./buttonCustomIdFunctions"; -import { splitButtonsIntoRows } from "./splitButtonsIntoRows"; - -export async function handleOpenMenu( - pluginData: GuildPluginData, - int: MessageComponentInteraction, - group: TButtonPairOpts, - context, -) { - const menuButtons: MessageButton[] = []; - if (group.button_menus == null) { - await int.reply({ - content: `A configuration error was encountered, please contact the Administrators!`, - ephemeral: true, - }); - pluginData.getPlugin(LogsPlugin).logBotAlert({ - body: `**A configuration error occurred** on buttons for message ${int.message.id}, no menus found in config`, - }); - return; - } - - for (const menuButton of Object.values(group.button_menus[context.roleOrMenu])) { - const customId = await generateStatelessCustomId(pluginData, context.groupName, menuButton.role_or_menu); - - const btn = new MessageButton() - .setLabel(menuButton.label ?? "") - .setStyle("PRIMARY") - .setCustomId(customId) - .setDisabled(menuButton.disabled ?? false); - - if (menuButton.emoji) { - const emo = pluginData.client.emojis.resolve(menuButton.emoji as Snowflake) ?? menuButton.emoji; - btn.setEmoji(emo); - } - menuButtons.push(btn); - } - - if (menuButtons.length === 0) { - await int.reply({ - content: `A configuration error was encountered, please contact the Administrators!`, - ephemeral: true, - }); - pluginData.getPlugin(LogsPlugin).logBotAlert({ - body: `**A configuration error occurred** on buttons for message ${int.message.id}, menu **${context.roleOrMenu}** not found in config`, - }); - return; - } - const rows = splitButtonsIntoRows(menuButtons, Object.values(group.button_menus[context.roleOrMenu])); // new MessageActionRow().addComponents(menuButtons); - - int.reply({ content: `Click to add/remove a role`, components: rows, ephemeral: true }); -} - -export async function handleModifyRole( - pluginData: GuildPluginData, - int: MessageComponentInteraction, - group: TButtonPairOpts, - context, -) { - const role = await pluginData.guild.roles.fetch(context.roleOrMenu); - if (!role) { - await int.reply({ - content: `A configuration error was encountered, please contact the Administrators!`, - ephemeral: true, - }); - pluginData.getPlugin(LogsPlugin).logBotAlert({ - body: `**A configuration error occurred** on buttons for message ${int.message.id}, role **${context.roleOrMenu}** not found on server`, - }); - return; - } - - const member = await pluginData.guild.members.fetch(int.user.id); - try { - if (member.roles.cache.has(role.id)) { - await member.roles.remove(role, `Button Roles on message ${int.message.id}`); - await int.reply({ content: `Role **${role.name}** removed`, ephemeral: true }); - } else { - await member.roles.add(role, `Button Roles on message ${int.message.id}`); - await int.reply({ content: `Role **${role.name}** added`, ephemeral: true }); - } - } catch (e) { - await int.reply({ - content: "A configuration error was encountered, please contact the Administrators!", - ephemeral: true, - }); - pluginData.getPlugin(LogsPlugin).logBotAlert({ - body: `**A configuration error occurred** on buttons for message ${int.message.id}, error: ${e}. We might be missing permissions!`, - }); - } -} diff --git a/backend/src/plugins/ReactionRoles/util/buttonCustomIdFunctions.ts b/backend/src/plugins/ReactionRoles/util/buttonCustomIdFunctions.ts deleted file mode 100644 index fa3644da..00000000 --- a/backend/src/plugins/ReactionRoles/util/buttonCustomIdFunctions.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Snowflake } from "discord.js"; -import { GuildPluginData } from "knub"; -import { ReactionRolesPluginType } from "../types"; -import { ButtonMenuActions } from "./buttonMenuActions"; - -export const BUTTON_CONTEXT_SEPARATOR = ":rb:"; - -export async function getButtonAction(pluginData: GuildPluginData, roleOrMenu: string) { - if (await pluginData.guild.roles.fetch(roleOrMenu as Snowflake).catch(() => false)) { - return ButtonMenuActions.MODIFY_ROLE; - } else { - return ButtonMenuActions.OPEN_MENU; - } -} - -export async function generateStatelessCustomId( - pluginData: GuildPluginData, - groupName: string, - roleOrMenu: string, -) { - let id = groupName + BUTTON_CONTEXT_SEPARATOR; - - id += `${await getButtonAction(pluginData, roleOrMenu)}${BUTTON_CONTEXT_SEPARATOR}${roleOrMenu}`; - - return id; -} - -export async function resolveStatefulCustomId(pluginData: GuildPluginData, id: string) { - const button = await pluginData.state.buttonRoles.getForButtonId(id); - - if (button) { - const group = pluginData.config.get().button_groups[button.button_group]; - if (!group) return null; - const cfgButton = group.default_buttons[button.button_name]; - - return { - groupName: button.button_group, - action: await getButtonAction(pluginData, cfgButton.role_or_menu), - roleOrMenu: cfgButton.role_or_menu, - stateless: false, - }; - } else { - return null; - } -} diff --git a/backend/src/plugins/ReactionRoles/util/buttonMenuActions.ts b/backend/src/plugins/ReactionRoles/util/buttonMenuActions.ts deleted file mode 100644 index 89c77ff7..00000000 --- a/backend/src/plugins/ReactionRoles/util/buttonMenuActions.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum ButtonMenuActions { - OPEN_MENU = "goto", - MODIFY_ROLE = "grant", -} diff --git a/backend/src/plugins/ReactionRoles/util/splitButtonsIntoRows.ts b/backend/src/plugins/ReactionRoles/util/splitButtonsIntoRows.ts deleted file mode 100644 index 8d73ab0a..00000000 --- a/backend/src/plugins/ReactionRoles/util/splitButtonsIntoRows.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { MessageActionRow, MessageButton } from "discord.js"; -import { TButtonOpts } from "../types"; - -export function splitButtonsIntoRows(actualButtons: MessageButton[], configButtons: TButtonOpts[]): MessageActionRow[] { - const rows: MessageActionRow[] = []; - let curRow = new MessageActionRow(); - let consecutive = 0; - - for (let i = 0; i < actualButtons.length; i++) { - const aBtn = actualButtons[i]; - const cBtn = configButtons[i]; - - curRow.addComponents(aBtn); - if (((consecutive + 1) % 5 === 0 || cBtn.end_row) && i + 1 < actualButtons.length) { - rows.push(curRow); - curRow = new MessageActionRow(); - consecutive = 0; - } else { - consecutive++; - } - } - - if (curRow.components.length >= 1) rows.push(curRow); - return rows; -} - -export function getRowCount(configButtons: TButtonOpts[]): number { - let count = 1; - let consecutive = 0; - for (let i = 0; i < configButtons.length; i++) { - const cBtn = configButtons[i]; - - if (((consecutive + 1) % 5 === 0 || cBtn.end_row) && i + 1 < configButtons.length) { - count++; - consecutive = 0; - } else { - consecutive++; - } - } - - return count; -} diff --git a/backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts b/backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts new file mode 100644 index 00000000..aa76140a --- /dev/null +++ b/backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts @@ -0,0 +1,68 @@ +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"; +import { pluginInfo } from "./info"; +import { createButtonComponents } from "./functions/createButtonComponents"; +import { TooManyComponentsError } from "./functions/TooManyComponentsError"; + +export const RoleButtonsPlugin = zeppelinGuildPlugin()({ + name: "role_buttons", + configSchema: ConfigSchema, + info: pluginInfo, + showInDocs: true, + + 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; + + 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); + } + } + + if (buttonsConfig.options) { + try { + createButtonComponents(buttonsConfig); + } catch (err) { + if (err instanceof TooManyComponentsError) { + throw new StrictValidationError(["Too many options; can only have max 5 buttons per row on max 5 rows."]); + } + throw new StrictValidationError(["Error validating options"]); + } + } + } + } + + 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..1eeb8f91 --- /dev/null +++ b/backend/src/plugins/RoleButtons/events/buttonInteraction.ts @@ -0,0 +1,70 @@ +import { typedGuildEventListener } from "knub"; +import { RoleButtonsPluginType, TRoleButtonOption } from "../types"; +import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin"; +import { GuildMember } from "discord.js"; +import { getAllRolesInButtons } from "../functions/getAllRolesInButtons"; +import { parseCustomId } from "../../../utils/parseCustomId"; + +export const onButtonInteraction = typedGuildEventListener()({ + event: "interactionCreate", + async listener({ pluginData, args }) { + if (!args.interaction.isButton()) { + return; + } + + const { namespace, data } = parseCustomId(args.interaction.customId); + if (namespace !== "roleButtons") { + return; + } + + const config = pluginData.config.get(); + const { name, index: optionIndex } = data; + // For some reason TS's type inference fails here so using a type annotation + const buttons = config.buttons[name]; + const option: TRoleButtonOption | undefined = buttons?.options[optionIndex]; + if (!buttons || !option) { + args.interaction.reply({ + ephemeral: true, + content: "Invalid option selected", + }); + return; + } + + const member = args.interaction.member as GuildMember; + const role = pluginData.guild.roles.cache.get(option.role_id); + const roleName = role?.name || option.role_id; + + const rolesToRemove: string[] = []; + const rolesToAdd: string[] = []; + + if (member.roles.cache.has(option.role_id)) { + rolesToRemove.push(option.role_id); + args.interaction.reply({ + ephemeral: true, + content: `The role **${roleName}** will be removed shortly!`, + }); + } else { + rolesToAdd.push(option.role_id); + + if (buttons.exclusive) { + for (const roleId of getAllRolesInButtons(buttons)) { + if (member.roles.cache.has(roleId)) { + rolesToRemove.push(roleId); + } + } + } + + args.interaction.reply({ + ephemeral: true, + content: `You will receive the **${roleName}** role shortly!`, + }); + } + + for (const roleId of rolesToAdd) { + pluginData.getPlugin(RoleManagerPlugin).addRole(member.user.id, roleId); + } + for (const roleId of rolesToRemove) { + pluginData.getPlugin(RoleManagerPlugin).removeRole(member.user.id, roleId); + } + }, +}); diff --git a/backend/src/plugins/RoleButtons/functions/TooManyComponentsError.ts b/backend/src/plugins/RoleButtons/functions/TooManyComponentsError.ts new file mode 100644 index 00000000..b79be8bb --- /dev/null +++ b/backend/src/plugins/RoleButtons/functions/TooManyComponentsError.ts @@ -0,0 +1 @@ +export class TooManyComponentsError extends Error {} 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..b171bc2b --- /dev/null +++ b/backend/src/plugins/RoleButtons/functions/applyRoleButtons.ts @@ -0,0 +1,114 @@ +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 { buildCustomId } from "../../../utils/buildCustomId"; +import { createButtonComponents } from "./createButtonComponents"; + +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 components = createButtonComponents(configItem); + await message.edit({ components }); + + return { + channel_id: message.channelId, + message_id: message.id, + }; +} diff --git a/backend/src/plugins/RoleButtons/functions/createButtonComponents.ts b/backend/src/plugins/RoleButtons/functions/createButtonComponents.ts new file mode 100644 index 00000000..05736484 --- /dev/null +++ b/backend/src/plugins/RoleButtons/functions/createButtonComponents.ts @@ -0,0 +1,39 @@ +import { MessageActionRow, MessageButton, Snowflake } from "discord.js"; +import { chunkArray } from "../../../utils"; +import { RoleButtonsPluginType, TRoleButtonOption, TRoleButtonsConfigItem } from "../types"; +import { buildCustomId } from "../../../utils/buildCustomId"; +import { GuildPluginData } from "knub"; +import { TooManyComponentsError } from "./TooManyComponentsError"; + +export function createButtonComponents(configItem: TRoleButtonsConfigItem): MessageActionRow[] { + const rows: MessageActionRow[] = []; + + let currentRow = new MessageActionRow(); + for (const [index, option] of configItem.options.entries()) { + if (currentRow.components.length === 5 || (currentRow.components.length > 0 && option.start_new_row)) { + rows.push(currentRow); + currentRow = new MessageActionRow(); + } + + const button = new MessageButton() + .setLabel(option.label ?? "") + .setStyle(option.style ?? "PRIMARY") + .setCustomId(buildCustomId("roleButtons", { name: configItem.name, index })); + + if (option.emoji) { + button.setEmoji(option.emoji); + } + + currentRow.components.push(button); + } + + if (currentRow.components.length > 0) { + rows.push(currentRow); + } + + if (rows.length > 5) { + throw new TooManyComponentsError(); + } + + return rows; +} diff --git a/backend/src/plugins/RoleButtons/functions/getAllRolesInButtons.ts b/backend/src/plugins/RoleButtons/functions/getAllRolesInButtons.ts new file mode 100644 index 00000000..c55c5990 --- /dev/null +++ b/backend/src/plugins/RoleButtons/functions/getAllRolesInButtons.ts @@ -0,0 +1,10 @@ +import { TRoleButtonsConfigItem } from "../types"; + +// This function will be more complex in the future when the plugin supports select menus + sub-menus +export function getAllRolesInButtons(buttons: TRoleButtonsConfigItem): string[] { + const roles = new Set(); + for (const option of buttons.options) { + roles.add(option.role_id); + } + return Array.from(roles); +} diff --git a/backend/src/plugins/RoleButtons/info.ts b/backend/src/plugins/RoleButtons/info.ts new file mode 100644 index 00000000..7b0976a5 --- /dev/null +++ b/backend/src/plugins/RoleButtons/info.ts @@ -0,0 +1,80 @@ +import { trimPluginDescription } from "../../utils"; +import { ZeppelinGuildPluginBlueprint } from "../ZeppelinPluginBlueprint"; + +export const pluginInfo: ZeppelinGuildPluginBlueprint["info"] = { + prettyName: "Role buttons", + description: trimPluginDescription(` + Allow users to pick roles by clicking on buttons + `), + configurationGuide: trimPluginDescription(` + Button roles are entirely config-based; this is in contrast to the old reaction roles. They can either be added to an existing message posted by Zeppelin or posted as a new message. + + ## Basic role buttons + ~~~yml + role_buttons: + config: + buttons: + my_roles: # You can use any name you want here, but make sure not to change it afterwards + messages: + channel_id: "967407495544983552" + content: "Click the reactions below to get roles! Click again to remove the role." + options: + - role_id: "878339100015489044" + label: "Role 1" + - role_id: "967410091571703808" + emoji: "😁" # Default emoji as a unicode emoji + label: "Role 2" + - role_id: "967410091571703234" + emoji: "967412591683047445" # Custom emoji ID + - role_id: "967410091571703567" + label: "Role 4" + style: DANGER # Button style (in all caps), see https://discord.com/developers/docs/interactions/message-components#button-object-button-styles + ~~~ + + ### Or with an embed: + ~~~yml + role_buttons: + config: + buttons: + my_roles: + messages: + channel_id: "967407495544983552" + content: + embeds: + - title: "Pick your role below!" + color: 0x0088FF + description: "You can pick any role you want by clicking the buttons below." + options: + ... # See above for examples for options + ~~~ + + ## Role buttons for an existing message + This message must be posted by Zeppelin. + ~~~yml + role_buttons: + config: + buttons: + my_roles: + messages: + channel_id: "967407495544983552" + message_id: "967407554412040193" + options: + ... # See above for examples for options + ~~~ + + ## Limiting to one role ("exclusive" roles) + When the \`exclusive\` option is enabled, only one role can be selected at a time. + ~~~yml + role_buttons: + config: + buttons: + my_roles: + messages: + channel_id: "967407495544983552" + message_id: "967407554412040193" + exclusive: true # With this option set, only one role can be selected at a time + options: + ... # See above for examples for options + ~~~ + `), +}; diff --git a/backend/src/plugins/RoleButtons/types.ts b/backend/src/plugins/RoleButtons/types.ts new file mode 100644 index 00000000..2b23b692 --- /dev/null +++ b/backend/src/plugins/RoleButtons/types.ts @@ -0,0 +1,50 @@ +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 + start_new_row: tNullable(t.boolean), +}); +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), + exclusive: tNullable(t.boolean), +}); +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/Tags/docs.ts b/backend/src/plugins/Tags/docs.ts index efdb0b86..bc1608bb 100644 --- a/backend/src/plugins/Tags/docs.ts +++ b/backend/src/plugins/Tags/docs.ts @@ -3,9 +3,9 @@ import { TemplateFunction } from "./types"; export function generateTemplateMarkdown(definitions: TemplateFunction[]): string { return definitions - .map(def => { + .map((def) => { const usage = def.signature ?? `(${def.arguments.join(", ")})`; - const examples = def.examples?.map(ex => `> \`{${ex}}\``).join("\n") ?? null; + const examples = def.examples?.map((ex) => `> \`{${ex}}\``).join("\n") ?? null; return trimPluginDescription(` ## ${def.name} **${def.description}**\n diff --git a/backend/src/plugins/ZeppelinPluginBlueprint.ts b/backend/src/plugins/ZeppelinPluginBlueprint.ts index 0bc7dbce..bb8cc70d 100644 --- a/backend/src/plugins/ZeppelinPluginBlueprint.ts +++ b/backend/src/plugins/ZeppelinPluginBlueprint.ts @@ -27,7 +27,7 @@ export interface ZeppelinGuildPluginBlueprint> = [ @@ -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 9a37511e..fb505743 100644 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -327,7 +327,7 @@ export const zEmbedInput = z.object({ title: z.string().optional(), description: z.string().optional(), url: z.string().optional(), - timestamp: z.number().optional(), + timestamp: z.string().optional(), color: z.number().optional(), footer: z.optional( @@ -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/buildCustomId.ts b/backend/src/utils/buildCustomId.ts new file mode 100644 index 00000000..3930d112 --- /dev/null +++ b/backend/src/utils/buildCustomId.ts @@ -0,0 +1,3 @@ +export function buildCustomId(namespace: string, data: any = {}) { + return `${namespace}:${Date.now()}:${JSON.stringify(data)}`; +} diff --git a/backend/src/utils/parseCustomId.ts b/backend/src/utils/parseCustomId.ts new file mode 100644 index 00000000..2d95a3a1 --- /dev/null +++ b/backend/src/utils/parseCustomId.ts @@ -0,0 +1,17 @@ +const customIdFormat = /^([^:]+):\d+:(.*)$/; + +export function parseCustomId(customId: string): { namespace: string; data: any } { + const parts = customId.match(customIdFormat); + if (!parts) { + return { + namespace: "", + data: null, + }; + } + + return { + namespace: parts[1], + // Skipping timestamp + data: JSON.parse(parts[2]), + }; +} diff --git a/backend/src/utils/permissionNames.ts b/backend/src/utils/permissionNames.ts index 20ef3d41..433fcb98 100644 --- a/backend/src/utils/permissionNames.ts +++ b/backend/src/utils/permissionNames.ts @@ -22,7 +22,6 @@ export const PERMISSION_NAMES: Record = { MANAGE_ROLES: "Manage Roles", MANAGE_THREADS: "Manage Threads", MANAGE_WEBHOOKS: "Manage Webhooks", - MANAGE_EVENTS: "Manage Events", MENTION_EVERYONE: `Mention @${EMPTY_CHAR}everyone, @${EMPTY_CHAR}here, and All Roles`, MOVE_MEMBERS: "Move Members", MUTE_MEMBERS: "Mute Members", @@ -45,4 +44,5 @@ export const PERMISSION_NAMES: Record = { VIEW_CHANNEL: "View Channels", VIEW_GUILD_INSIGHTS: "View Guild Insights", MODERATE_MEMBERS: "Moderate Members", + MANAGE_EVENTS: "Manage Events", }; diff --git a/dashboard/src/components/docs/Plugin.vue b/dashboard/src/components/docs/Plugin.vue index ab1dddf3..68c15f26 100644 --- a/dashboard/src/components/docs/Plugin.vue +++ b/dashboard/src/components/docs/Plugin.vue @@ -8,6 +8,20 @@ +
+
+
+ +
+
+ Note! This is a legacy plugin which is no longer actively maintained and may be removed in a future update. +
+ +
+
+
+
+ Usage @@ -172,12 +186,13 @@ import Tab from "../Tab.vue"; import Expandable from "../Expandable.vue"; import { DocsState } from "../../store/types"; + import Alert from 'vue-material-design-icons/Alert.vue'; const validTabs = ['usage', 'configuration']; const defaultTab = 'usage'; export default { - components: { CodeBlock, MarkdownBlock, Tabs, Tab, Expandable }, + components: { CodeBlock, MarkdownBlock, Tabs, Tab, Expandable, Alert }, async mounted() { this.loading = true;