From 28860ea8b65fa599e1f71b50f2f6759b547e004c Mon Sep 17 00:00:00 2001 From: Essem Date: Sun, 3 Aug 2025 13:56:24 -0500 Subject: [PATCH] feat: Add role-based tag gating Tags are now only allowed to be created by members with the Administrator permission by default. The role subcommands allow for adding, removing, and listing extra roles to allow more members to create tags. This also includes a few other fixes related to tags and database handling, as I found/fixed them on the same branch as this was being worked on and they were a bit too intertwined with the changes here to separate. --- commands/tags/tags.js | 170 ++++++++++++++++++++++++++++++++++++- locales/en-US.json | 10 ++- src/classes/command.ts | 12 +++ src/database.ts | 2 + src/database/postgresql.ts | 23 ++++- src/database/sqlite.ts | 63 ++++++++++---- src/utils/mentions.ts | 94 ++++++++++++++++++++ src/utils/types.ts | 1 + 8 files changed, 354 insertions(+), 21 deletions(-) create mode 100644 src/utils/mentions.ts diff --git a/commands/tags/tags.js b/commands/tags/tags.js index 6a5f8502..058c0d9a 100644 --- a/commands/tags/tags.js +++ b/commands/tags/tags.js @@ -3,8 +3,10 @@ import { Constants } from "oceanic.js"; import Command from "#cmd-classes/command.js"; import paginator from "#pagination"; import { random } from "#utils/misc.js"; +import { mentionToObject } from "#utils/mentions.js"; const blacklist = ["create", "add", "edit", "remove", "delete", "list", "random", "own", "owner"]; +// todo: implement proper subcommand support in the command handler class TagsCommand extends Command { async run() { this.success = false; @@ -16,7 +18,7 @@ class TagsCommand extends Command { ? (this.args[0] ?? "").toLowerCase() : this.interaction?.data.options.getSubCommand()?.[0]; if (!cmd || !cmd.trim()) return this.getString("commands.responses.tags.noInput"); - const tagName = this.type === "classic" ? this.args.slice(1)[0] : this.interaction?.data.options.getString("name"); + const tagName = this.type === "classic" ? this.args.slice(1)[0] : this.getOptionString("name"); switch (cmd) { case "create": @@ -32,6 +34,8 @@ class TagsCommand extends Command { return await this.owner(tagName); case "list": return await this.list(); + case "role": + return this.role(); default: return await this.get(tagName, cmd); } @@ -70,6 +74,10 @@ class TagsCommand extends Command { */ async create(tagName) { if (!this.database || !this.guild) return; + const guild = await this.database.getGuild(this.guild.id); + const setConv = new Set(guild.tag_roles); + if (!setConv.has(this.guild.id) && (!this.member || this.member.roles.filter((r) => setConv.has(r)).length == 0)) + return this.getString("commands.responses.tags.noRolePerms"); if (!tagName || !tagName.trim()) return this.getString("commands.responses.tags.addName"); if (blacklist.includes(tagName)) return this.getString("commands.responses.tags.invalidName"); const getResult = await this.database.getTag(this.guild.id, tagName); @@ -229,6 +237,126 @@ class TagsCommand extends Command { ); } + async role() { + if (!this.database || !this.guild) return; + const subcommand = + this.type === "classic" + ? (this.args.slice(1)[0] ?? "").toLowerCase() + : this.interaction?.data.options.getSubCommand()?.[1]; + + switch (subcommand) { + case "add": + return this.roleAdd(); + case "delete": + case "remove": + return this.roleRemove(); + case "list": + return this.roleList(); + default: + return this.get("role", "role"); + } + } + + async roleAdd() { + if (!this.database || !this.guild) return; + const owners = process.env.OWNER?.split(","); + if (!this.memberPermissions.has("MANAGE_MESSAGES") && !owners?.includes(this.author.id)) + return this.getString("commands.responses.tags.notOwnerRole"); + + let role = this.type === "classic" ? this.args.slice(2)[0] : this.getOptionRole("role"); + if (typeof role === "string") { + if (role === "@everyone" || role === "everyone") { + role = this.guild.id; + } + role = await mentionToObject(this.client, role, "role", { + guild: this.guild, + }); + } + + if (!role) return this.getString("commands.responses.tags.noRole"); + const guild = await this.database.getGuild(this.guild.id); + if (guild.tag_roles.includes(role.id)) return this.getString("commands.responses.tags.existingRole"); + await this.database.addTagRole(this.guild.id, role.id); + this.success = true; + return this.getString("commands.responses.tags.roleAdded", { + params: { + name: role.name, + }, + }); + } + + async roleRemove() { + if (!this.database || !this.guild) return; + const owners = process.env.OWNER?.split(","); + if (!this.memberPermissions.has("MANAGE_MESSAGES") && !owners?.includes(this.author.id)) + return this.getString("commands.responses.tags.notOwnerRole"); + + let role = this.type === "classic" ? this.args.slice(2)[0] : this.getOptionRole("role"); + if (typeof role === "string") { + if (role === "@everyone" || role === "everyone") { + role = this.guild.id; + } + role = await mentionToObject(this.client, role, "role", { + guild: this.guild, + }); + } + + if (!role) return this.getString("commands.responses.tags.noRole"); + const guild = await this.database.getGuild(this.guild.id); + if (!guild.tag_roles.includes(role.id)) return this.getString("commands.responses.tags.missingRole"); + await this.database.removeTagRole(this.guild.id, role.id); + this.success = true; + return this.getString("commands.responses.tags.roleRemoved", { + params: { + name: role.name, + }, + }); + } + + async roleList() { + if (!this.database || !this.guild) return; + if (!this.permissions.has("EMBED_LINKS")) return this.getString("permissions.noEmbedLinks"); + const guild = await this.database.getGuild(this.guild.id); + const embeds = []; + const groups = []; + let arrIndex = 0; + const roleMentions = guild.tag_roles.map((v) => `<@&${v}>`); + for (let i = 0; i < roleMentions.length; i += 15) { + groups[arrIndex] = roleMentions.slice(i, i + 15); + arrIndex++; + } + for (const [i, value] of groups.entries()) { + embeds.push({ + embeds: [ + { + title: this.getString("commands.responses.tags.roleList"), + color: 0xff0000, + footer: { + text: this.getString("pagination.page", { + params: { + page: (i + 1).toString(), + amount: groups.length.toString(), + }, + }), + }, + description: value?.join("\n"), + author: { + name: this.author.username, + iconURL: this.author.avatarURL(), + }, + }, + ], + }); + } + if (embeds.length === 0) return this.getString("commands.responses.tags.noRoles"); + this.success = true; + return paginator( + this.client, + { message: this.message, interaction: this.interaction, author: this.author }, + embeds, + ); + } + static description = "The main tags command. Check the help page for more info: https://esmbot.net/help.html"; static aliases = ["t", "tag", "ta"]; @@ -294,6 +422,46 @@ class TagsCommand extends Command { type: Constants.ApplicationCommandOptionTypes.SUB_COMMAND, description: "Gets a random tag", }, + { + name: "role", + type: Constants.ApplicationCommandOptionTypes.SUB_COMMAND_GROUP, + description: "Manage role permissions for tags", + options: [ + { + name: "add", + type: Constants.ApplicationCommandOptionTypes.SUB_COMMAND, + description: "Allow a role to manage tags", + options: [ + { + name: "role", + type: Constants.ApplicationCommandOptionTypes.ROLE, + description: "The role to add", + required: true, + classic: true, + }, + ], + }, + { + name: "remove", + type: Constants.ApplicationCommandOptionTypes.SUB_COMMAND, + description: "Remove a role from the allowlist", + options: [ + { + name: "role", + type: Constants.ApplicationCommandOptionTypes.ROLE, + description: "The role to remove", + required: true, + classic: true, + }, + ], + }, + { + name: "list", + type: Constants.ApplicationCommandOptionTypes.SUB_COMMAND, + description: "List the roles that are capable of managing tags", + }, + ], + }, ]; static directAllowed = false; static userAllowed = false; diff --git a/locales/en-US.json b/locales/en-US.json index 44c6dd19..2287b175 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -903,12 +903,20 @@ "invalid": "That tag doesn't exist!", "invalidName": "You can't make a tag with that name!", "list": "Tag List", + "missingRole": "You can't remove a role that hasn't already been added!", "noInput": "You need to provide the name of the tag you want to view!", + "noRole": "You need to provide a role to add/remove!", + "noRolePerms": "You don't have permission to create tags!", + "noRoles": "No roles are currently allowed to manage tags!", "noTags": "I couldn't find any tags!", "notOwner": "You don't own this tag!", + "notOwnerRole": "You need to have the \"Manage Messages\" permission to add/remove tag roles!", "ownedBy": "This tag is owned by **{{user}}** (`{{id}}`).", "ownedById": "I couldn't find exactly who owns this tag, but I was able to get their ID: `{{id}}`", - "ownerName": "You need to provide the name of the tag you want to check the owner of!" + "ownerName": "You need to provide the name of the tag you want to check the owner of!", + "roleAdded": "The role `{{name}}` has been added to the list of allowed roles.", + "roleList": "Allowed Roles", + "roleRemoved": "The role `{{name}}` has been removed from the list of allowed roles." }, "toggle": { "notHost": "Only the current voice session host can pause/resume the music!", diff --git a/src/classes/command.ts b/src/classes/command.ts index 5b1dbd17..2e8f8ebe 100644 --- a/src/classes/command.ts +++ b/src/classes/command.ts @@ -15,6 +15,7 @@ import { type Message, type MessageReference, Permission, + type Role, TextableChannel, type Uncached, type User, @@ -230,6 +231,17 @@ class Command { throw Error("Unknown command type"); } + getOptionRole(key: string, defaultArg?: boolean): Role | undefined { + if (this.type === "classic") { + const id = defaultArg ? this.args.join(" ").trim() : this.options?.[key]; + return this.guild?.roles.get(id as string); + } + if (this.type === "application") { + return this.interaction?.data.options.getRole(key); + } + throw Error("Unknown command type"); + } + // Note: the key is unused in a classic command context. getOptionAttachment(key: string): Attachment | undefined { if (this.type === "classic") { diff --git a/src/database.ts b/src/database.ts index bacd038f..51823b9c 100644 --- a/src/database.ts +++ b/src/database.ts @@ -22,6 +22,8 @@ export declare class DatabasePlugin { setTag: (tag: Tag, guild: Guild) => Promise; removeTag: (name: string, guild: Guild) => Promise; editTag: (tag: Tag, guild: Guild) => Promise; + addTagRole: (guild: string, role: string) => Promise; + removeTagRole: (guild: string, role: string) => Promise; setBroadcast: (msg?: string) => Promise; getBroadcast: () => Promise; setPrefix: (prefix: string, guild: Guild) => Promise; diff --git a/src/database/postgresql.ts b/src/database/postgresql.ts index 17156cc4..f8d0a98b 100644 --- a/src/database/postgresql.ts +++ b/src/database/postgresql.ts @@ -26,7 +26,8 @@ CREATE TABLE guilds ( guild_id VARCHAR(30) NOT NULL PRIMARY KEY, prefix VARCHAR(15) NOT NULL, disabled text ARRAY NOT NULL, - disabled_commands text ARRAY NOT NULL + disabled_commands text ARRAY NOT NULL, + tag_roles VARCHAR(30) ARRAY DEFAULT [] NOT NULL ); CREATE TABLE counts ( command VARCHAR NOT NULL PRIMARY KEY, @@ -46,6 +47,7 @@ const updates = [ "CREATE TABLE IF NOT EXISTS settings ( id smallint PRIMARY KEY, version integer NOT NULL, CHECK(id = 1) );\nALTER TABLE guilds ADD COLUMN accessed timestamp;", "ALTER TABLE guilds DROP COLUMN accessed", "ALTER TABLE settings ADD COLUMN IF NOT EXISTS broadcast text", + "ALTER TABLE guilds ADD COLUMN IF NOT EXISTS tag_roles VARCHAR(30) ARRAY DEFAULT array[]::varchar[] NOT NULL", ]; export default class PostgreSQLPlugin implements DatabasePlugin { @@ -106,7 +108,13 @@ export default class PostgreSQLPlugin implements DatabasePlugin { this.sql.begin(async (sql) => { let [guild]: [DBGuild?] = await sql`SELECT * FROM guilds WHERE guild_id = ${query}`; if (!guild) { - guild = { guild_id: query, prefix: process.env.PREFIX ?? "&", disabled: [], disabled_commands: [] }; + guild = { + guild_id: query, + prefix: process.env.PREFIX ?? "&", + disabled: [], + disabled_commands: [], + tag_roles: [], + }; await sql`INSERT INTO guilds ${sql(guild)}`; } resolve(guild); @@ -147,6 +155,17 @@ export default class PostgreSQLPlugin implements DatabasePlugin { await this.sql`DELETE FROM tags WHERE guild_id = ${guild.id} AND name = ${name}`; } + async addTagRole(guild: string, role: string) { + const guildDB = await this.getGuild(guild); + await this.sql`UPDATE guilds SET tag_roles = ${[...guildDB.tag_roles, role]} WHERE guild_id = ${guild}`; + } + + async removeTagRole(guild: string, role: string) { + const guildDB = await this.getGuild(guild); + await this + .sql`UPDATE guilds SET tag_roles = ${guildDB.tag_roles.filter((v) => v !== role)} WHERE guild_id = ${guild}`; + } + async setBroadcast(msg?: string) { await this.sql`UPDATE settings SET broadcast = ${msg ?? null} WHERE id = 1`; } diff --git a/src/database/sqlite.ts b/src/database/sqlite.ts index 34853eaf..f0da3b5d 100644 --- a/src/database/sqlite.ts +++ b/src/database/sqlite.ts @@ -42,7 +42,8 @@ CREATE TABLE guilds ( guild_id VARCHAR(30) NOT NULL PRIMARY KEY, prefix VARCHAR(15) NOT NULL, disabled text NOT NULL, - disabled_commands text NOT NULL + disabled_commands text NOT NULL, + tag_roles VARCHAR DEFAULT '[]' ); CREATE TABLE counts ( command VARCHAR NOT NULL PRIMARY KEY, @@ -73,6 +74,7 @@ const updates = [ CHECK(id = 1) ); INSERT INTO settings (id) VALUES (1);`, + "ALTER TABLE guilds ADD COLUMN tag_roles VARCHAR DEFAULT '[]'", ]; export default class SQLitePlugin implements DatabasePlugin { @@ -218,7 +220,7 @@ export default class SQLitePlugin implements DatabasePlugin { author: tag.author, }; this.connection - .prepare("INSERT INTO tags (guild_id, name, content, author) VALUES (@guild_id, @name, @content, @author)") + .prepare("INSERT INTO tags (guild_id, name, content, author) VALUES (:guild_id, :name, :content, :author)") .run(tagData); } @@ -232,6 +234,20 @@ export default class SQLitePlugin implements DatabasePlugin { .run(tag.content, tag.author, guild.id, tag.name); } + async addTagRole(guild: string, role: string) { + const guildDB = await this.getGuild(guild); + this.connection + .prepare("UPDATE guilds SET tag_roles = ? WHERE guild_id = ?") + .run(JSON.stringify([...guildDB.tag_roles, role]), guild); + } + + async removeTagRole(guild: string, role: string) { + const guildDB = await this.getGuild(guild); + this.connection + .prepare("UPDATE guilds SET tag_roles = ? WHERE guild_id = ?") + .run(JSON.stringify(guildDB.tag_roles.filter((v) => v !== role)), guild); + } + async setBroadcast(msg?: string) { this.connection.prepare("UPDATE settings SET broadcast = ? WHERE id = 1").run(msg); } @@ -249,36 +265,49 @@ export default class SQLitePlugin implements DatabasePlugin { } async getGuild(query: string): Promise { - let guild: DBGuild | undefined; + // SQLite does not support arrays, so instead we convert them from strings + let guild: + | ({ + disabled: string; + disabled_commands: string; + tag_roles: string; + } & Omit) + | undefined; this.connection.transaction(() => { - guild = this.connection.prepare("SELECT * FROM guilds WHERE guild_id = ?").get(query) as DBGuild; + guild = this.connection.prepare("SELECT * FROM guilds WHERE guild_id = ?").get(query) as { + disabled: string; + disabled_commands: string; + tag_roles: string; + } & Omit; if (!guild) { const guild_id = query; const prefix = process.env.PREFIX ?? "&"; - this.connection - .prepare( - "INSERT INTO guilds (guild_id, prefix, disabled, disabled_commands) VALUES (@guild_id, @prefix, @disabled, @disabled_commands)", - ) - .run({ - guild_id, - prefix, - disabled: "[]", - disabled_commands: "[]", - }); guild = { guild_id, prefix, - disabled: [], - disabled_commands: [], + disabled: "[]", + disabled_commands: "[]", + tag_roles: "[]", }; + this.connection + .prepare( + "INSERT INTO guilds (guild_id, prefix, disabled, disabled_commands, tag_roles) VALUES (:guild_id, :prefix, :disabled, :disabled_commands, :tag_roles)", + ) + .run(guild); } })(); + if (guild) { + guild.disabled = JSON.parse(guild.disabled); + guild.disabled_commands = JSON.parse(guild.disabled_commands); + guild.tag_roles = JSON.parse(guild.tag_roles); + } return ( - guild ?? { + (guild as DBGuild | undefined) ?? { guild_id: query, prefix: process.env.PREFIX ?? "&", disabled: [], disabled_commands: [], + tag_roles: [], } ); } diff --git a/src/utils/mentions.ts b/src/utils/mentions.ts new file mode 100644 index 00000000..1a2e2ea2 --- /dev/null +++ b/src/utils/mentions.ts @@ -0,0 +1,94 @@ +import type { AnyChannel, Client, Guild, Member, Role, User } from "oceanic.js"; +import { safeBigInt } from "./misc.ts"; + +const mentionRegex = /^?$/; + +interface MentionToObjectParams { + guild?: Guild | null; + server?: boolean; + rest?: boolean; +} + +type MentionTypes = "user" | "role" | "channel"; + +export async function mentionToObject( + client: Client, + mention: string, + type: "user", + options: MentionToObjectParams, +): Promise; +export async function mentionToObject( + client: Client, + mention: string, + type: "role", + options: MentionToObjectParams, +): Promise; +export async function mentionToObject( + client: Client, + mention: string, + type: "channel", + options: MentionToObjectParams, +): Promise; +export async function mentionToObject( + client: Client, + mention: string, + type: MentionTypes, + options: MentionToObjectParams, +) { + let obj; + if (validID(mention)) { + if (type === "user") { + obj = await getUser(client, options.guild, mention, options.server, options.rest); + } else if (type === "role") { + obj = await getRole(client, options.guild!, mention); + } else if (type === "channel") { + obj = await getChannel(client, mention); + } + } else if (mentionRegex.test(mention)) { + const id = mention.match(mentionRegex)?.[1]; + if (id && validID(id)) { + if (type === "user") { + obj = await getUser(client, options.guild, id, options.server, options.rest); + } else if (type === "role") { + obj = await getRole(client, options.guild!, id); + } else if (type === "channel") { + obj = await getChannel(client, id); + } + } + } + return obj; +} + +function validID(id: string) { + return safeBigInt(id) > 21154535154122752n; +} + +async function getChannel(client: Client, id: string) { + let channel = client.getChannel(id); + if (!channel) channel = await client.rest.channels.get(id); + return channel; +} + +async function getRole(client: Client, guild: Guild, id: string) { + let role = guild?.roles.get(id); + if (!role && guild) role = await client.rest.guilds.getRole(guild.id, id); + return role; +} + +export async function getUser( + client: Client, + guild: Guild | null | undefined, + id: string, + member = false, + rest = false, +): Promise { + let user; + if (member && guild) { + if (!rest) user = guild.members.get(id); + if (!user) user = await client.rest.guilds.getMember(guild.id, id); + } else { + if (!rest) user = client.users.get(id); + if (!user) user = await client.rest.users.get(id); + } + return user; +} diff --git a/src/utils/types.ts b/src/utils/types.ts index 119a26e4..fb2d8546 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -6,6 +6,7 @@ export interface DBGuild { prefix: string; disabled: string[]; disabled_commands: string[]; + tag_roles: string[]; } export interface Tag { -- 2.51.2