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 {