diff --git a/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts b/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts new file mode 100644 index 00000000..09c1d796 --- /dev/null +++ b/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts @@ -0,0 +1,95 @@ +import { PluginOptions } from "knub"; +import { SelfGrantableRolesPluginType, ConfigSchema, defaultSelfGrantableRoleEntry } from "./types"; +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { trimPluginDescription } from "src/utils"; +import { RoleAddCmd } from "./commands/RoleAddCmd"; +import { RoleRemoveCmd } from "./commands/RoleRemoveCmd"; +import { RoleHelpCmd } from "./commands/RoleHelpCmd"; + +const defaultOptions: PluginOptions = { + config: { + entries: {}, + mention_roles: false, + }, +}; + +export const SelfGrantableRolesPlugin = zeppelinPlugin()("self_grantable_roles", { + configSchema: ConfigSchema, + defaultOptions, + + info: { + prettyName: "Self-grantable roles", + description: trimPluginDescription(` + Allows users to grant themselves roles via a command + `), + configurationGuide: trimPluginDescription(` + ### Basic configuration + In this example, users can add themselves platform roles on the channel 473087035574321152 by using the + \`!role\` command. For example, \`!role pc ps4\` to add both the "pc" and "ps4" roles as specified below. + + ~~~yml + self_grantable_roles: + config: + entries: + basic: + roles: + "543184300250759188": ["pc", "computer"] + "534710505915547658": ["ps4", "ps", "playstation"] + "473085927053590538": ["xbox", "xb1", "xb"] + overrides: + - channel: "473087035574321152" + config: + entries: + basic: + roles: + can_use: true + ~~~ + + ### Maximum number of roles + This is identical to the basic example above, but users can only choose 1 role. + + ~~~yml + self_grantable_roles: + config: + entries: + basic: + roles: + "543184300250759188": ["pc", "computer"] + "534710505915547658": ["ps4", "ps", "playstation"] + "473085927053590538": ["xbox", "xb1", "xb"] + max_roles: 1 + overrides: + - channel: "473087035574321152" + config: + entries: + basic: + roles: + can_use: true + ~~~ + `), + }, + + configPreprocessor: options => { + const config = options.config; + 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 { ...options, config }; + }, + + // prettier-ignore + commands: [ + RoleHelpCmd, + RoleRemoveCmd, + RoleAddCmd, + ] +}); diff --git a/backend/src/plugins/SelfGrantableRoles/commands/RoleAddCmd.ts b/backend/src/plugins/SelfGrantableRoles/commands/RoleAddCmd.ts new file mode 100644 index 00000000..411e266f --- /dev/null +++ b/backend/src/plugins/SelfGrantableRoles/commands/RoleAddCmd.ts @@ -0,0 +1,124 @@ +import { selfGrantableRolesCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { getApplyingEntries } from "../util/getApplyingEntries"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; +import { splitRoleNames } from "../util/splitRoleNames"; +import { normalizeRoleNames } from "../util/normalizeRoleNames"; +import { findMatchingRoles } from "../util/findMatchingRoles"; +import { Role } from "eris"; + +export const RoleAddCmd = selfGrantableRolesCmd({ + trigger: ["role", "role add"], + permission: null, + + signature: { + roleNames: ct.string({ rest: true }), + }, + + async run({ message: msg, args, pluginData }) { + const lock = await pluginData.locks.acquire(`grantableRoles:${msg.author.id}`); + + const applyingEntries = getApplyingEntries(pluginData, msg); + if (applyingEntries.length === 0) { + lock.unlock(); + return; + } + + const roleNames = normalizeRoleNames(splitRoleNames(args.roleNames)); + const matchedRoleIds = findMatchingRoles(roleNames, applyingEntries); + + const hasUnknownRoles = matchedRoleIds.length !== roleNames.length; + + const rolesToAdd: Map = Array.from(matchedRoleIds.values()) + .map(id => pluginData.guild.roles.get(id)) + .filter(Boolean) + .reduce((map, role) => { + map.set(role.id, role); + return map; + }, new Map()); + + if (!rolesToAdd.size) { + sendErrorMessage( + pluginData, + msg.channel, + `<@!${msg.author.id}> Unknown ${args.roleNames.length === 1 ? "role" : "roles"}`, + ); + lock.unlock(); + return; + } + + // Grant the roles + const newRoleIds = new Set([...rolesToAdd.keys(), ...msg.member.roles]); + + // Remove extra roles (max_roles) for each entry + const skipped: Set = new Set(); + const removed: Set = new Set(); + + 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(pluginData.guild.roles.get(roleId)); + } else { + skipped.add(pluginData.guild.roles.get(roleId)); + } + } + } + } + } + + try { + await msg.member.edit({ + roles: Array.from(newRoleIds), + }); + } catch (e) { + sendErrorMessage( + pluginData, + msg.channel, + `<@!${msg.author.id}> Got an error while trying to grant you the roles`, + ); + return; + } + + const mentionRoles = pluginData.config.get().mention_roles; + const addedRolesStr = Array.from(rolesToAdd.values()).map(r => (mentionRoles ? `<@&${r.id}>` : `**${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 => (mentionRoles ? `<@&${r.id}>` : `**${r.name}**`)) + .join(",") + : null; + const removedRolesStr = removed.size + ? "removed " + Array.from(removed.values()).map(r => (mentionRoles ? `<@&${r.id}>` : `**${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"); + } + + sendSuccessMessage(pluginData, msg.channel, `<@!${msg.author.id}> ${messageParts.join("; ")}`); + + lock.unlock(); + }, +}); diff --git a/backend/src/plugins/SelfGrantableRoles/commands/RoleHelpCmd.ts b/backend/src/plugins/SelfGrantableRoles/commands/RoleHelpCmd.ts new file mode 100644 index 00000000..388ac618 --- /dev/null +++ b/backend/src/plugins/SelfGrantableRoles/commands/RoleHelpCmd.ts @@ -0,0 +1,52 @@ +import { selfGrantableRolesCmd } from "../types"; +import { asSingleLine, trimLines } from "src/utils"; +import { getApplyingEntries } from "../util/getApplyingEntries"; + +export const RoleHelpCmd = selfGrantableRolesCmd({ + trigger: ["role help", "role"], + permission: null, + + async run({ message: msg, pluginData }) { + const applyingEntries = getApplyingEntries(pluginData, 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 = pluginData.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 \`${prefix}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 }); + }, +}); diff --git a/backend/src/plugins/SelfGrantableRoles/commands/RoleRemoveCmd.ts b/backend/src/plugins/SelfGrantableRoles/commands/RoleRemoveCmd.ts new file mode 100644 index 00000000..e41a8bc8 --- /dev/null +++ b/backend/src/plugins/SelfGrantableRoles/commands/RoleRemoveCmd.ts @@ -0,0 +1,75 @@ +import { selfGrantableRolesCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { getApplyingEntries } from "../util/getApplyingEntries"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; +import { splitRoleNames } from "../util/splitRoleNames"; +import { normalizeRoleNames } from "../util/normalizeRoleNames"; +import { findMatchingRoles } from "../util/findMatchingRoles"; + +export const RoleRemoveCmd = selfGrantableRolesCmd({ + trigger: "role remove", + permission: null, + + signature: { + roleNames: ct.string({ rest: true }), + }, + + async run({ message: msg, args, pluginData }) { + const lock = await pluginData.locks.acquire(`grantableRoles:${msg.author.id}`); + + const applyingEntries = getApplyingEntries(pluginData, msg); + if (applyingEntries.length === 0) { + lock.unlock(); + return; + } + + const roleNames = normalizeRoleNames(splitRoleNames(args.roleNames)); + const matchedRoleIds = findMatchingRoles(roleNames, applyingEntries); + + const rolesToRemove = Array.from(matchedRoleIds.values()).map(id => pluginData.guild.roles.get(id)); + const roleIdsToRemove = rolesToRemove.map(r => r.id); + + // Remove the roles + if (rolesToRemove.length) { + const newRoleIds = msg.member.roles.filter(roleId => !roleIdsToRemove.includes(roleId)); + + try { + await msg.member.edit({ + roles: newRoleIds, + }); + + const removedRolesStr = rolesToRemove.map(r => `**${r.name}**`); + const removedRolesWord = rolesToRemove.length === 1 ? "role" : "roles"; + + if (rolesToRemove.length !== roleNames.length) { + sendSuccessMessage( + pluginData, + msg.channel, + `<@!${msg.author.id}> Removed ${removedRolesStr.join(", ")} ${removedRolesWord};` + + ` couldn't recognize the other roles you mentioned`, + ); + } else { + sendSuccessMessage( + pluginData, + msg.channel, + `<@!${msg.author.id}> Removed ${removedRolesStr.join(", ")} ${removedRolesWord}`, + ); + } + } catch (e) { + sendSuccessMessage( + pluginData, + msg.channel, + `<@!${msg.author.id}> Got an error while trying to remove the roles`, + ); + } + } else { + sendErrorMessage( + pluginData, + msg.channel, + `<@!${msg.author.id}> Unknown ${args.roleNames.length === 1 ? "role" : "roles"}`, + ); + } + + lock.unlock(); + }, +}); diff --git a/backend/src/plugins/SelfGrantableRoles/types.ts b/backend/src/plugins/SelfGrantableRoles/types.ts new file mode 100644 index 00000000..724f697d --- /dev/null +++ b/backend/src/plugins/SelfGrantableRoles/types.ts @@ -0,0 +1,31 @@ +import * as t from "io-ts"; +import { BasePluginType, command } from "knub"; + +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); +export type TSelfGrantableRoleEntry = t.TypeOf; + +export const ConfigSchema = t.type({ + entries: t.record(t.string, SelfGrantableRoleEntry), + mention_roles: t.boolean, +}); +type TConfigSchema = t.TypeOf; + +export const defaultSelfGrantableRoleEntry: t.TypeOf = { + can_use: false, + can_ignore_cooldown: false, + max_roles: 0, +}; + +export interface SelfGrantableRolesPluginType extends BasePluginType { + config: TConfigSchema; +} + +export const selfGrantableRolesCmd = command(); diff --git a/backend/src/plugins/SelfGrantableRoles/util/findMatchingRoles.ts b/backend/src/plugins/SelfGrantableRoles/util/findMatchingRoles.ts new file mode 100644 index 00000000..eec62a07 --- /dev/null +++ b/backend/src/plugins/SelfGrantableRoles/util/findMatchingRoles.ts @@ -0,0 +1,15 @@ +import { TSelfGrantableRoleEntry } from "../types"; + +export function 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); +} diff --git a/backend/src/plugins/SelfGrantableRoles/util/getApplyingEntries.ts b/backend/src/plugins/SelfGrantableRoles/util/getApplyingEntries.ts new file mode 100644 index 00000000..39860bc9 --- /dev/null +++ b/backend/src/plugins/SelfGrantableRoles/util/getApplyingEntries.ts @@ -0,0 +1,15 @@ +import { TSelfGrantableRoleEntry, SelfGrantableRolesPluginType } from "../types"; +import { PluginData } from "knub"; + +export function getApplyingEntries( + pluginData: PluginData, + msg, +): TSelfGrantableRoleEntry[] { + const config = pluginData.config.getForMessage(msg); + return Object.entries(config.entries) + .filter( + ([k, e]) => + e.can_use && !(!e.can_ignore_cooldown && pluginData.state.cooldowns.isOnCooldown(`${k}:${msg.author.id}`)), + ) + .map(pair => pair[1]); +} diff --git a/backend/src/plugins/SelfGrantableRoles/util/normalizeRoleNames.ts b/backend/src/plugins/SelfGrantableRoles/util/normalizeRoleNames.ts new file mode 100644 index 00000000..7f19a7bb --- /dev/null +++ b/backend/src/plugins/SelfGrantableRoles/util/normalizeRoleNames.ts @@ -0,0 +1,3 @@ +export function normalizeRoleNames(roleNames: string[]) { + return roleNames.map(v => v.toLowerCase()); +} diff --git a/backend/src/plugins/SelfGrantableRoles/util/splitRoleNames.ts b/backend/src/plugins/SelfGrantableRoles/util/splitRoleNames.ts new file mode 100644 index 00000000..efd460d3 --- /dev/null +++ b/backend/src/plugins/SelfGrantableRoles/util/splitRoleNames.ts @@ -0,0 +1,6 @@ +export function splitRoleNames(roleNames: string[]) { + return roleNames + .map(v => v.split(/[\s,]+/)) + .flat() + .filter(Boolean); +} diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index 54b2cb26..e5b2ffa6 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -22,6 +22,7 @@ import { RolesPlugin } from "./Roles/RolesPlugin"; import { SlowmodePlugin } from "./Slowmode/SlowmodePlugin"; import { StarboardPlugin } from "./Starboard/StarboardPlugin"; import { ChannelArchiverPlugin } from "./ChannelArchiver/ChannelArchiverPlugin"; +import { SelfGrantableRolesPlugin } from "./SelfGrantableRoles/SelfGrantableRolesPlugin"; import { SpamPlugin } from "./Spam/SpamPlugin"; import { ReactionRolesPlugin } from "./ReactionRoles/ReactionRolesPlugin"; import { AutomodPlugin } from "./Automod/AutomodPlugin"; @@ -43,6 +44,7 @@ export const guildPlugins: Array> = [ NameHistoryPlugin, RemindersPlugin, RolesPlugin, + SelfGrantableRolesPlugin, SlowmodePlugin, SpamPlugin, StarboardPlugin,