From b403db51885463d8daf1dcea69b8a6fc89c89cd2 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Wed, 22 Jan 2020 01:27:04 +0200 Subject: [PATCH] Refactor SelfGrantableRoles to use config instead of command management, add max_roles option --- backend/src/data/GuildSelfGrantableRoles.ts | 38 -- .../src/data/entities/SelfGrantableRole.ts | 16 - .../src/plugins/SelfGrantableRolesPlugin.ts | 440 ++++++++---------- 3 files changed, 203 insertions(+), 291 deletions(-) delete mode 100644 backend/src/data/GuildSelfGrantableRoles.ts delete mode 100644 backend/src/data/entities/SelfGrantableRole.ts diff --git a/backend/src/data/GuildSelfGrantableRoles.ts b/backend/src/data/GuildSelfGrantableRoles.ts deleted file mode 100644 index b2fe2b50..00000000 --- a/backend/src/data/GuildSelfGrantableRoles.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { BaseGuildRepository } from "./BaseGuildRepository"; -import { getRepository, Repository } from "typeorm"; -import { SelfGrantableRole } from "./entities/SelfGrantableRole"; - -export class GuildSelfGrantableRoles extends BaseGuildRepository { - private selfGrantableRoles: Repository; - - constructor(guildId) { - super(guildId); - this.selfGrantableRoles = getRepository(SelfGrantableRole); - } - - async getForChannel(channelId: string): Promise { - return this.selfGrantableRoles.find({ - where: { - guild_id: this.guildId, - channel_id: channelId, - }, - }); - } - - async delete(channelId: string, roleId: string) { - await this.selfGrantableRoles.delete({ - guild_id: this.guildId, - channel_id: channelId, - role_id: roleId, - }); - } - - async add(channelId: string, roleId: string, aliases: string[]) { - await this.selfGrantableRoles.insert({ - guild_id: this.guildId, - channel_id: channelId, - role_id: roleId, - aliases, - }); - } -} diff --git a/backend/src/data/entities/SelfGrantableRole.ts b/backend/src/data/entities/SelfGrantableRole.ts deleted file mode 100644 index a3a310e8..00000000 --- a/backend/src/data/entities/SelfGrantableRole.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Entity, Column, PrimaryColumn } from "typeorm"; - -@Entity("self_grantable_roles") -export class SelfGrantableRole { - @Column() - @PrimaryColumn() - id: number; - - @Column() guild_id: string; - - @Column() channel_id: string; - - @Column() role_id: string; - - @Column("simple-array") aliases: string[]; -} diff --git a/backend/src/plugins/SelfGrantableRolesPlugin.ts b/backend/src/plugins/SelfGrantableRolesPlugin.ts index 675249d0..0c4963f1 100644 --- a/backend/src/plugins/SelfGrantableRolesPlugin.ts +++ b/backend/src/plugins/SelfGrantableRolesPlugin.ts @@ -1,103 +1,161 @@ import { decorators as d, IPluginOptions } from "knub"; -import { GuildSelfGrantableRoles } from "../data/GuildSelfGrantableRoles"; import { GuildChannel, Message, Role, TextChannel } from "eris"; -import { asSingleLine, chunkArray, errorMessage, sorter, successMessage, trimLines } from "../utils"; +import { asSingleLine, chunkArray, errorMessage, sorter, successMessage, tDeepPartial, trimLines } from "../utils"; import { ZeppelinPlugin } from "./ZeppelinPlugin"; import * as t from "io-ts"; -const ConfigSchema = t.type({ - can_manage: t.boolean, +const RoleMap = t.record(t.string, t.array(t.string)); + +const SelfGrantableRoleEntry = t.type({ + roles: RoleMap, can_use: t.boolean, can_ignore_cooldown: t.boolean, + max_roles: t.number, +}); +const PartialRoleEntry = t.partial(SelfGrantableRoleEntry.props); +type TSelfGrantableRoleEntry = t.TypeOf; + +const ConfigSchema = t.type({ + entries: t.record(t.string, SelfGrantableRoleEntry), }); type TConfigSchema = t.TypeOf; +const PartialConfigSchema = tDeepPartial(ConfigSchema); + +const defaultSelfGrantableRoleEntry: t.TypeOf = { + can_use: false, + can_ignore_cooldown: false, + max_roles: 0, +}; + export class SelfGrantableRolesPlugin extends ZeppelinPlugin { public static pluginName = "self_grantable_roles"; public static showInDocs = false; public static configSchema = ConfigSchema; - protected selfGrantableRoles: GuildSelfGrantableRoles; - public static getStaticDefaultOptions(): IPluginOptions { return { config: { - can_manage: false, - can_use: false, - can_ignore_cooldown: false, + entries: {}, }, - - overrides: [ - { - level: ">=50", - config: { - can_ignore_cooldown: true, - }, - }, - { - level: ">=100", - config: { - can_manage: true, - }, - }, - ], }; } - onLoad() { - this.selfGrantableRoles = GuildSelfGrantableRoles.getGuildInstance(this.guildId); + protected static preprocessStaticConfig(config: t.TypeOf) { + for (const [key, entry] of Object.entries(config.entries)) { + // Apply default entry config + config.entries[key] = { ...defaultSelfGrantableRoleEntry, ...entry }; + + // Normalize alias names + if (entry.roles) { + for (const [roleId, aliases] of Object.entries(entry.roles)) { + entry.roles[roleId] = aliases.map(a => a.toLowerCase()); + } + } + } + + return config; + } + + protected splitRoleNames(roleNames: string[]) { + return roleNames + .map(v => v.split(/[\s,]+/)) + .flat() + .filter(Boolean); + } + + protected normalizeRoleNames(roleNames: string[]) { + return roleNames.map(v => v.toLowerCase()); + } + + protected getApplyingEntries(msg): TSelfGrantableRoleEntry[] { + const config = this.getConfigForMsg(msg); + return Object.entries(config.entries) + .filter( + ([k, e]) => e.can_use && !(!e.can_ignore_cooldown && this.cooldowns.isOnCooldown(`${k}:${msg.author.id}`)), + ) + .map(pair => pair[1]); + } + + protected findMatchingRoles(roleNames, entries: TSelfGrantableRoleEntry[]): string[] { + const aliasToRoleId = entries.reduce((map, entry) => { + for (const [roleId, aliases] of Object.entries(entry.roles)) { + for (const alias of aliases) { + map.set(alias, roleId); + } + } + + return map; + }, new Map()); + + return roleNames.map(roleName => aliasToRoleId.get(roleName)).filter(Boolean); + } + + @d.command("role help", [], { + aliases: ["role"], + }) + async roleHelpCmd(msg: Message) { + const applyingEntries = this.getApplyingEntries(msg); + if (applyingEntries.length === 0) return; + + const allPrimaryAliases = []; + for (const entry of applyingEntries) { + for (const aliases of Object.values(entry.roles)) { + if (aliases[0]) { + allPrimaryAliases.push(aliases[0]); + } + } + } + + const prefix = this.guildConfig.prefix; + const [firstRole, secondRole] = allPrimaryAliases; + + const help1 = asSingleLine(` + To give yourself a role, type e.g. \`${prefix}role ${firstRole}\` where **${firstRole}** is the role you want. + ${secondRole ? `You can also add multiple roles at once, e.g. \`${prefix}role ${firstRole} ${secondRole}\`` : ""} + `); + + const help2 = asSingleLine(` + To remove a role, type \`!role remove ${firstRole}\`, + again replacing **${firstRole}** with the role you want to remove. + `); + + const helpMessage = trimLines(` + ${help1} + + ${help2} + + **Roles available to you:** + ${allPrimaryAliases.join(", ")} + `); + + const helpEmbed = { + title: "How to get roles", + description: helpMessage, + color: parseInt("42bff4", 16), + }; + + msg.channel.createMessage({ embed: helpEmbed }); } @d.command("role remove", "") - @d.permission("can_use") - @d.cooldown(2500, "can_ignore_cooldown") async roleRemoveCmd(msg: Message, args: { roleNames: string[] }) { const lock = await this.locks.acquire(`grantableRoles:${msg.author.id}`); - const channelGrantableRoles = await this.selfGrantableRoles.getForChannel(msg.channel.id); - if (channelGrantableRoles.length === 0) { + const applyingEntries = this.getApplyingEntries(msg); + if (applyingEntries.length === 0) { lock.unlock(); return; } - const nonMatchingRoleNames: string[] = []; - const rolesToRemove: Set = new Set(); + const roleNames = this.normalizeRoleNames(this.splitRoleNames(args.roleNames)); + const matchedRoleIds = this.findMatchingRoles(roleNames, applyingEntries); - // Match given role names with actual grantable roles - const roleNames = new Set( - args.roleNames - .map(n => n.split(/[\s,]+/)) - .flat() - .map(v => v.toLowerCase()), - ); - for (const roleName of roleNames) { - let matched = false; - - for (const grantableRole of channelGrantableRoles) { - let matchedAlias = false; - - for (const alias of grantableRole.aliases) { - const normalizedAlias = alias.toLowerCase(); - if (roleName === normalizedAlias && this.guild.roles.has(grantableRole.role_id)) { - rolesToRemove.add(this.guild.roles.get(grantableRole.role_id)); - matched = true; - matchedAlias = true; - break; - } - } - - if (matchedAlias) break; - } - - if (!matched) { - nonMatchingRoleNames.push(roleName); - } - } + const rolesToRemove = Array.from(matchedRoleIds.values()).map(id => this.guild.roles.get(id)); + const roleIdsToRemove = rolesToRemove.map(r => r.id); // Remove the roles - if (rolesToRemove.size) { - const rolesToRemoveArr = Array.from(rolesToRemove.values()); - const roleIdsToRemove = rolesToRemoveArr.map(r => r.id); + if (rolesToRemove.length) { const newRoleIds = msg.member.roles.filter(roleId => !roleIdsToRemove.includes(roleId)); try { @@ -105,10 +163,10 @@ export class SelfGrantableRolesPlugin extends ZeppelinPlugin { roles: newRoleIds, }); - const removedRolesStr = rolesToRemoveArr.map(r => `**${r.name}**`); - const removedRolesWord = rolesToRemoveArr.length === 1 ? "role" : "roles"; + const removedRolesStr = rolesToRemove.map(r => `**${r.name}**`); + const removedRolesWord = rolesToRemove.length === 1 ? "role" : "roles"; - if (nonMatchingRoleNames.length) { + if (rolesToRemove.length !== roleNames.length) { this.sendSuccessMessage( msg.channel, `<@!${msg.author.id}> Removed ${removedRolesStr.join(", ")} ${removedRolesWord};` + @@ -133,194 +191,102 @@ export class SelfGrantableRolesPlugin extends ZeppelinPlugin { } @d.command("role", "") - @d.permission("can_use") - @d.cooldown(1500, "can_ignore_cooldown") async roleCmd(msg: Message, args: { roleNames: string[] }) { const lock = await this.locks.acquire(`grantableRoles:${msg.author.id}`); - const channelGrantableRoles = await this.selfGrantableRoles.getForChannel(msg.channel.id); - if (channelGrantableRoles.length === 0) { + const applyingEntries = this.getApplyingEntries(msg); + if (applyingEntries.length === 0) { lock.unlock(); return; } - const nonMatchingRoleNames: string[] = []; - const rolesToGrant: Set = new Set(); + const roleNames = this.normalizeRoleNames(this.splitRoleNames(args.roleNames)); + const matchedRoleIds = this.findMatchingRoles(roleNames, applyingEntries); - // Match given role names with actual grantable roles - const roleNames = new Set( - args.roleNames - .map(n => n.split(/[\s,]+/)) - .flat() - .map(v => v.toLowerCase()), - ); + const hasUnknownRoles = matchedRoleIds.length !== roleNames.length; - for (const roleName of roleNames) { - let matched = false; + const rolesToAdd: Map = Array.from(matchedRoleIds.values()) + .map(id => this.guild.roles.get(id)) + .filter(Boolean) + .reduce((map, role) => { + map.set(role.id, role); + return map; + }, new Map()); - for (const grantableRole of channelGrantableRoles) { - let matchedAlias = false; - - for (const alias of grantableRole.aliases) { - const normalizedAlias = alias.toLowerCase(); - if (roleName === normalizedAlias && this.guild.roles.has(grantableRole.role_id)) { - rolesToGrant.add(this.guild.roles.get(grantableRole.role_id)); - matched = true; - matchedAlias = true; - break; - } - } - - if (matchedAlias) break; - } - - if (!matched) { - nonMatchingRoleNames.push(roleName); - } + if (!rolesToAdd.size) { + this.sendErrorMessage( + msg.channel, + `<@!${msg.author.id}> Unknown ${args.roleNames.length === 1 ? "role" : "roles"}`, + ); + lock.unlock(); + return; } // Grant the roles - if (rolesToGrant.size) { - const rolesToGrantArr = Array.from(rolesToGrant.values()); - const roleIdsToGrant = rolesToGrantArr.map(r => r.id); - const newRoleIds = Array.from(new Set(msg.member.roles.concat(roleIdsToGrant)).values()); - try { - await msg.member.edit({ - roles: newRoleIds, - }); + const newRoleIds = new Set([...rolesToAdd.keys(), ...msg.member.roles]); - const grantedRolesStr = rolesToGrantArr.map(r => `**${r.name}**`); - const grantedRolesWord = rolesToGrantArr.length === 1 ? "role" : "roles"; + // Remove extra roles (max_roles) for each entry + const skipped: Set = new Set(); + const removed: Set = new Set(); - if (nonMatchingRoleNames.length) { - this.sendSuccessMessage( - msg.channel, - `<@!${msg.author.id}> Granted you the ${grantedRolesStr.join(", ")} ${grantedRolesWord};` + - ` couldn't recognize the other roles you mentioned`, - ); - } else { - this.sendSuccessMessage( - msg.channel, - `<@!${msg.author.id}> Granted you the ${grantedRolesStr.join(", ")} ${grantedRolesWord}`, - ); + for (const entry of applyingEntries) { + if (entry.max_roles === 0) continue; + + let foundRoles = 0; + + for (const roleId of newRoleIds) { + if (entry.roles[roleId]) { + if (foundRoles < entry.max_roles) { + foundRoles++; + } else { + newRoleIds.delete(roleId); + rolesToAdd.delete(roleId); + + if (msg.member.roles.includes(roleId)) { + removed.add(this.guild.roles.get(roleId)); + } else { + skipped.add(this.guild.roles.get(roleId)); + } + } } - } catch (e) { - msg.channel.createMessage( - errorMessage(`<@!${msg.author.id}> Got an error while trying to grant you the roles`), - ); } - } else { - msg.channel.createMessage( - errorMessage(`<@!${msg.author.id}> Unknown ${args.roleNames.length === 1 ? "role" : "roles"}`), - ); } + try { + await msg.member.edit({ + roles: Array.from(newRoleIds), + }); + } catch (e) { + this.sendErrorMessage(msg.channel, `<@!${msg.author.id}> Got an error while trying to grant you the roles`); + return; + } + + const addedRolesStr = Array.from(rolesToAdd.values()).map(r => `**${r.name}**`); + const addedRolesWord = rolesToAdd.size === 1 ? "role" : "roles"; + + const messageParts = []; + messageParts.push(`Granted you the ${addedRolesStr.join(", ")} ${addedRolesWord}`); + + if (skipped.size || removed.size) { + const skippedRolesStr = skipped.size + ? "skipped " + + Array.from(skipped.values()) + .map(r => `**${r.name}**`) + .join(",") + : null; + const removedRolesStr = removed.size ? "removed " + Array.from(removed.values()).map(r => `**${r.name}**`) : null; + + const skippedRemovedStr = [skippedRolesStr, removedRolesStr].filter(Boolean).join(" and "); + + messageParts.push(`${skippedRemovedStr} due to role limits`); + } + + if (hasUnknownRoles) { + messageParts.push("couldn't recognize some of the roles"); + } + + this.sendSuccessMessage(msg.channel, `<@!${msg.author.id}> ${messageParts.join("; ")}`); + lock.unlock(); } - - @d.command("role help", [], { - aliases: ["role"], - }) - @d.permission("can_use") - @d.cooldown(5000, "can_ignore_cooldown") - async roleHelpCmd(msg: Message) { - const channelGrantableRoles = await this.selfGrantableRoles.getForChannel(msg.channel.id); - if (channelGrantableRoles.length === 0) return; - - const prefix = this.guildConfig.prefix; - const firstRole = channelGrantableRoles[0].aliases[0]; - const secondRole = channelGrantableRoles[1] ? channelGrantableRoles[1].aliases[0] : null; - - const help1 = asSingleLine(` - To give yourself a role, type e.g. \`${prefix}role ${firstRole}\` where **${firstRole}** is the role you want. - ${secondRole ? `You can also add multiple roles at once, e.g. \`${prefix}role ${firstRole} ${secondRole}\`` : ""} - `); - - const help2 = asSingleLine(` - To remove a role, type \`!role remove ${firstRole}\`, - again replacing **${firstRole}** with the role you want to remove. - `); - - const helpMessage = trimLines(` - ${help1} - - ${help2} - - **Available roles:** - ${channelGrantableRoles.map(r => r.aliases[0]).join(", ")} - `); - - const helpEmbed = { - title: "How to get roles", - description: helpMessage, - color: parseInt("42bff4", 16), - }; - - msg.channel.createMessage({ embed: helpEmbed }); - } - - @d.command("self_grantable_roles add", " [aliases:string...]") - @d.permission("can_manage") - async addSelfGrantableRoleCmd(msg: Message, args: { channel: GuildChannel; roleId: string; aliases?: string[] }) { - if (!(args.channel instanceof TextChannel)) { - msg.channel.createMessage(errorMessage("Invalid channel (must be a text channel)")); - return; - } - - const role = this.guild.roles.get(args.roleId); - if (!role) { - msg.channel.createMessage(errorMessage("Unknown role")); - return; - } - - const aliases = (args.aliases || []).map(n => n.split(/[\s,]+/)).flat(); - aliases.push(role.name.replace(/\s+/g, "")); - const uniqueAliases = Array.from(new Set(aliases).values()); - - // Remove existing self grantable role on that channel, if one exists - await this.selfGrantableRoles.delete(args.channel.id, role.id); - - // Add new one - await this.selfGrantableRoles.add(args.channel.id, role.id, uniqueAliases); - - this.sendSuccessMessage(msg.channel, `Self-grantable role **${role.name}** added to **#${args.channel.name}**`); - } - - @d.command("self_grantable_roles delete", " ") - @d.permission("can_manage") - async deleteSelfGrantableRoleCmd(msg: Message, args: { channel: GuildChannel; roleId: string }) { - await this.selfGrantableRoles.delete(args.channel.id, args.roleId); - - const roleName = this.guild.roles.has(args.roleId) ? this.guild.roles.get(args.roleId).name : args.roleId; - - this.sendSuccessMessage(msg.channel, `Self-grantable role **${roleName}** removed from **#${args.channel.name}**`); - } - - @d.command("self_grantable_roles", "") - @d.permission("can_manage") - async selfGrantableRolesCmd(msg: Message, args: { channel: GuildChannel }) { - if (!(args.channel instanceof TextChannel)) { - msg.channel.createMessage(errorMessage("Invalid channel (must be a text channel)")); - return; - } - - const channelGrantableRoles = await this.selfGrantableRoles.getForChannel(args.channel.id); - if (channelGrantableRoles.length === 0) { - msg.channel.createMessage(errorMessage(`No self-grantable roles on **#${args.channel.name}**`)); - return; - } - - channelGrantableRoles.sort(sorter(gr => gr.aliases.join(", "))); - - const longestId = channelGrantableRoles.reduce((longest, gr) => Math.max(longest, gr.role_id.length), 0); - const lines = channelGrantableRoles.map(gr => { - const paddedId = gr.role_id.padEnd(longestId, " "); - return `${paddedId} ${gr.aliases.join(", ")}`; - }); - - const batches = chunkArray(lines, 20); - for (const batch of batches) { - await msg.channel.createMessage(`\`\`\`js\n${batch.join("\n")}\n\`\`\``); - } - } }