diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a0635a..cdba4bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ + `/help` now has a **Privacy policy** button, and its footer says that problems and data requests go through a Support ticket. ++ `/help` only lists the commands you can run. + + Bot-admin commands (all of Management, `/links` and `/whois`) only show for members with a bot-admin role. + + Commands that need a Discord permission only show for members who have it. + + Every page says so: "Missing a command? You only see the ones your permissions in this server allow." #### Documentation diff --git a/DISCORD_CHANGELOG.md b/DISCORD_CHANGELOG.md index 9a99a01..c397124 100644 --- a/DISCORD_CHANGELOG.md +++ b/DISCORD_CHANGELOG.md @@ -16,6 +16,7 @@ bullets, `> ` quotes. Tables, `+` bullets and links with titles do not render. - The bridge has its own `/link` again, separate from the community bot's. If you were linked, your link was copied over, so you don't need to do anything. - Feature requests (`/request`) are on the community bot. - `/help` links our new privacy policy. Open a Support ticket to report a problem or ask for your data to be removed. +- `/help` now only shows the commands you can use. Missing one? You don't have the permission for it. - `/link` now tells you what linking means: your messages here go to guild chat, and a global profile change can show them under another linked member's name. ### Moderation diff --git a/docs/FEATURES.md b/docs/FEATURES.md index fd14ad1..ba80384 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -375,6 +375,9 @@ chat. everything the bot is responsible for. - **`/help`** — every command, one page per category, with ◀ / ▶ buttons. The categories are the folder names under `src/commands/`, so adding a folder adds a page. Commands needing a Discord permission are marked ⛔. + It only lists what the member can run: commands marked `adminOnly` show only to bot-admin roles, and + `permissionsRequired` must be met (Administrator passes). Every page ends with "Missing a command? You only + see the ones your permissions in this server allow." The buttons stop working after five minutes. Every page links the [privacy policy](https://thehypixelguardians.mintlify.site/privacy) and says that problems and data requests go through a Support ticket: Discord requires an app to tell users where to report problems and ask for diff --git a/src/commands/information/help.js b/src/commands/information/help.js index 6a85bef..9b6ca0b 100644 --- a/src/commands/information/help.js +++ b/src/commands/information/help.js @@ -8,10 +8,15 @@ const { } = require("discord.js"); const path = require("path"); const getAllFiles = require("../../utils/getAllFiles"); +const { isAdmin } = require("../../utils/adminRoles"); /** The privacy policy for both THG bots, linked from every help page. */ const PRIVACY_URL = "https://thehypixelguardians.mintlify.site/privacy"; +/** Shown on every page, since /help leaves out what the member can't run. */ +const MISSING_COMMAND_HINT = + "-# Missing a command? You only see the ones your permissions in this server allow."; + /** * Converts a camelCase folder name into a readable title. * e.g. "chatScore" → "Chat Score", "admin" → "Admin" @@ -34,9 +39,36 @@ function requiresAdminPermission(command) { return command.permissionsRequired.some((perm) => adminPerms.includes(perm)); } +/** + * Whether a viewer may see a command in /help: bot-admin-only commands need a + * bot-admin role, and `permissionsRequired` must all be held (Administrator + * passes; outside a server, `permissions` is null and only commands without + * any show). Mirrors the checks the commands themselves run. + * + * @param {{ adminOnly?: boolean, permissionsRequired?: bigint[] }} command + * @param {{ botAdmin: boolean, permissions: { has(p: bigint): boolean } | null }} viewer + */ +function canSeeCommand(command, viewer) { + if (command.adminOnly && !viewer.botAdmin) return false; + if (!command.permissionsRequired?.length) return true; + if (!viewer.permissions) return false; + if (viewer.permissions.has(PermissionFlagsBits.Administrator)) return true; + return command.permissionsRequired.every((perm) => + viewer.permissions.has(perm), + ); +} + +/** What decides which commands a member sees in /help. */ +async function viewerOf(interaction) { + return { + botAdmin: await isAdmin(interaction.member), + permissions: interaction.memberPermissions ?? null, + }; +} + /** * Builds an array of page objects, one per command category. - * Each page has { category: string, lines: string[] }. + * Each page has { category: string, entries: { line, adminOnly, permissionsRequired }[] }. * Result is cached so the filesystem is only scanned once per process. */ let cachedPages = null; @@ -52,13 +84,17 @@ function buildHelpPages() { const categoryName = path.basename(categoryPath); const readableName = parseCategoryName(categoryName); const commandFiles = getAllFiles(categoryPath).sort(); - const lines = []; + const entries = []; for (const commandFile of commandFiles) { const command = require(commandFile); if (command.deleted) continue; const adminEmoji = requiresAdminPermission(command) ? " ⛔" : ""; + const access = { + adminOnly: Boolean(command.adminOnly), + permissionsRequired: command.permissionsRequired, + }; const subcommands = (command.options || []).filter( (opt) => opt.type === ApplicationCommandOptionType.Subcommand || @@ -67,19 +103,21 @@ function buildHelpPages() { if (subcommands.length > 0) { for (const sub of subcommands) { - lines.push( - `\`/${command.name} ${sub.name}\` —${adminEmoji} ${sub.description}`, - ); + entries.push({ + ...access, + line: `\`/${command.name} ${sub.name}\` —${adminEmoji} ${sub.description}`, + }); } } else { - lines.push( - `\`/${command.name}\` —${adminEmoji} ${command.description}`, - ); + entries.push({ + ...access, + line: `\`/${command.name}\` —${adminEmoji} ${command.description}`, + }); } } - if (lines.length > 0) { - pages.push({ category: readableName, lines }); + if (entries.length > 0) { + pages.push({ category: readableName, entries }); } } @@ -87,16 +125,33 @@ function buildHelpPages() { return cachedPages; } +/** + * The pages one viewer gets: { category, lines }, without the commands they + * can't see and without categories left empty. + */ +function helpPagesFor(viewer, pages = buildHelpPages()) { + return pages + .map((page) => ({ + category: page.category, + lines: page.entries + .filter((entry) => canSeeCommand(entry, viewer)) + .map((entry) => entry.line), + })) + .filter((page) => page.lines.length > 0); +} + /** * Constructs the embed for the given page index. */ function buildEmbed(pages, pageIndex) { const page = pages[pageIndex]; + const hint = `\n\n${MISSING_COMMAND_HINT}`; let description = page.lines.join("\n"); - if (description.length > 4096) { - description = description.substring(0, 4093) + "..."; + if (description.length + hint.length > 4096) { + description = description.substring(0, 4093 - hint.length) + "..."; } + description += hint; return new EmbedBuilder() .setTitle(`📖 Help — ${page.category}`) @@ -138,7 +193,7 @@ module.exports = { description: "Browse all available commands by category.", callback: async (client, interaction) => { - const pages = buildHelpPages(); + const pages = helpPagesFor(await viewerOf(interaction)); if (pages.length === 0) { return interaction.reply({ @@ -172,6 +227,9 @@ module.exports = { }, buildHelpPages, + helpPagesFor, + viewerOf, + canSeeCommand, buildEmbed, buildButtons, PRIVACY_URL, diff --git a/src/commands/linking/links.js b/src/commands/linking/links.js index 34e9b04..25287b4 100644 --- a/src/commands/linking/links.js +++ b/src/commands/linking/links.js @@ -5,6 +5,8 @@ const { getAllLinks } = require("../../utils/linkedAccounts"); module.exports = { name: "links", description: "List every Minecraft account linked on the bridge.", + // Bot-admin roles only (checked in the callback); /help lists it only for them. + adminOnly: true, callback: async (client, interaction) => { if (!(await isAdmin(interaction.member))) { diff --git a/src/commands/linking/whois.js b/src/commands/linking/whois.js index fdc69cb..8bd930b 100644 --- a/src/commands/linking/whois.js +++ b/src/commands/linking/whois.js @@ -6,6 +6,8 @@ module.exports = { name: "whois", description: "Look up a bridge account link by Discord user or Minecraft username.", + // Bot-admin roles only (checked in the callback); /help lists it only for them. + adminOnly: true, options: [ { name: "user", diff --git a/src/commands/management/adminpanel.js b/src/commands/management/adminpanel.js index 2f43356..5638cd1 100644 --- a/src/commands/management/adminpanel.js +++ b/src/commands/management/adminpanel.js @@ -4,6 +4,8 @@ const { mainView } = require("../../utils/adminPanel"); module.exports = { name: "adminpanel", description: "Open the admin panel to run and configure admin functions.", + // Bot-admin roles only (checked in the callback); /help lists it only for them. + adminOnly: true, callback: async (client, interaction) => { if (!(await isAdmin(interaction.member))) { diff --git a/src/commands/management/auditchannel.js b/src/commands/management/auditchannel.js index bf2335e..52c0b7c 100644 --- a/src/commands/management/auditchannel.js +++ b/src/commands/management/auditchannel.js @@ -20,6 +20,8 @@ const SCOPED_GUILD_OPTION = { module.exports = { name: "auditchannel", description: "Configure the channel admin panel actions are recorded in.", + // Bot-admin roles only (checked in the callback); /help lists it only for them. + adminOnly: true, options: [ { name: "set", diff --git a/src/commands/management/blacklist.js b/src/commands/management/blacklist.js index eaa615f..b3714c0 100644 --- a/src/commands/management/blacklist.js +++ b/src/commands/management/blacklist.js @@ -21,6 +21,8 @@ module.exports = { name: "blacklist", description: "Block a member from both bots' commands and from the chat bridge.", + // Bot-admin roles only (checked in the callback); /help lists it only for them. + adminOnly: true, options: [ { name: "add", diff --git a/src/commands/management/demote.js b/src/commands/management/demote.js index e5a64a2..3c794e0 100644 --- a/src/commands/management/demote.js +++ b/src/commands/management/demote.js @@ -13,6 +13,8 @@ const { module.exports = { name: "demote", description: "Demote a member in a Hypixel guild.", + // Bot-admin roles only (checked in the callback); /help lists it only for them. + adminOnly: true, options: [ { name: "username", diff --git a/src/commands/management/guilds.js b/src/commands/management/guilds.js index 1207c37..7cd7c04 100644 --- a/src/commands/management/guilds.js +++ b/src/commands/management/guilds.js @@ -97,6 +97,8 @@ function beginSignIn(guildKey, interaction) { module.exports = { name: "guilds", description: "Manage the Hypixel guilds this bot bridges.", + // Bot-admin roles only (checked in the callback); /help lists it only for them. + adminOnly: true, options: [ { name: "list", diff --git a/src/commands/management/invite.js b/src/commands/management/invite.js index 6d1db64..d80a740 100644 --- a/src/commands/management/invite.js +++ b/src/commands/management/invite.js @@ -13,6 +13,8 @@ const { module.exports = { name: "invite", description: "Invite a player to a Hypixel guild.", + // Bot-admin roles only (checked in the callback); /help lists it only for them. + adminOnly: true, options: [ { name: "username", diff --git a/src/commands/management/kick.js b/src/commands/management/kick.js index 7701c0a..9c19c64 100644 --- a/src/commands/management/kick.js +++ b/src/commands/management/kick.js @@ -14,6 +14,8 @@ const { module.exports = { name: "kick", description: "Kick a member from a Hypixel guild.", + // Bot-admin roles only (checked in the callback); /help lists it only for them. + adminOnly: true, options: [ { name: "username", diff --git a/src/commands/management/login.js b/src/commands/management/login.js index 741618d..90bc4ad 100644 --- a/src/commands/management/login.js +++ b/src/commands/management/login.js @@ -34,6 +34,8 @@ async function waitForSpawn(record) { module.exports = { name: "login", description: "Connect the bot to Hypixel.", + // Bot-admin roles only (checked in the callback); /help lists it only for them. + adminOnly: true, options: [ { ...GUILD_OPTION, diff --git a/src/commands/management/mute.js b/src/commands/management/mute.js index 7a85691..40b4c26 100644 --- a/src/commands/management/mute.js +++ b/src/commands/management/mute.js @@ -18,6 +18,8 @@ const { module.exports = { name: "mute", description: "Mute a member in a Hypixel guild's chat.", + // Bot-admin roles only (checked in the callback); /help lists it only for them. + adminOnly: true, options: [ { name: "username", diff --git a/src/commands/management/promote.js b/src/commands/management/promote.js index fc9e9c2..e22c617 100644 --- a/src/commands/management/promote.js +++ b/src/commands/management/promote.js @@ -17,6 +17,8 @@ const { module.exports = { name: "promote", description: "Give a member a rank, in-game and on Discord.", + // Bot-admin roles only (checked in the callback); /help lists it only for them. + adminOnly: true, options: [ { name: "user", diff --git a/src/commands/management/send.js b/src/commands/management/send.js index 89ee201..cd71edc 100644 --- a/src/commands/management/send.js +++ b/src/commands/management/send.js @@ -12,6 +12,8 @@ const { module.exports = { name: "send", description: "Send a command or message to the Minecraft server.", + // Bot-admin roles only (checked in the callback); /help lists it only for them. + adminOnly: true, options: [ { name: "message", diff --git a/src/commands/management/unmute.js b/src/commands/management/unmute.js index e42b5f5..1958772 100644 --- a/src/commands/management/unmute.js +++ b/src/commands/management/unmute.js @@ -15,6 +15,8 @@ const { module.exports = { name: "unmute", description: "Unmute a member in a Hypixel guild's chat.", + // Bot-admin roles only (checked in the callback); /help lists it only for them. + adminOnly: true, options: [ { name: "username", diff --git a/src/events/discord/interactionCreate/handleHelpButtons.js b/src/events/discord/interactionCreate/handleHelpButtons.js index c4a72da..e7400fd 100644 --- a/src/events/discord/interactionCreate/handleHelpButtons.js +++ b/src/events/discord/interactionCreate/handleHelpButtons.js @@ -1,5 +1,6 @@ const { - buildHelpPages, + helpPagesFor, + viewerOf, buildEmbed, buildButtons, } = require("../../../commands/information/help"); @@ -48,7 +49,10 @@ module.exports = async (client, interaction) => { }); } - const pages = buildHelpPages(); + const pages = helpPagesFor(await viewerOf(interaction)); + if (pages.length === 0) { + return interaction.update({ components: [] }); + } const newPage = Math.max( 0, Math.min( diff --git a/test/help.test.js b/test/help.test.js new file mode 100644 index 0000000..bfffde8 --- /dev/null +++ b/test/help.test.js @@ -0,0 +1,78 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { PermissionFlagsBits, PermissionsBitField } = require("discord.js"); +const { + canSeeCommand, + helpPagesFor, + buildEmbed, +} = require("../src/commands/information/help"); + +const perms = (...flags) => new PermissionsBitField(flags); +const member = { botAdmin: false, permissions: perms() }; +const admin = { botAdmin: true, permissions: perms() }; + +test("bot-admin commands only show for bot-admin roles", () => { + assert.equal(canSeeCommand({ adminOnly: true }, member), false); + assert.equal(canSeeCommand({ adminOnly: true }, admin), true); + assert.equal(canSeeCommand({}, member), true); +}); + +test("permissionsRequired must all be held, Administrator passes", () => { + const command = { + permissionsRequired: [ + PermissionFlagsBits.KickMembers, + PermissionFlagsBits.BanMembers, + ], + }; + assert.equal(canSeeCommand(command, member), false); + assert.equal( + canSeeCommand(command, { + botAdmin: false, + permissions: perms(PermissionFlagsBits.KickMembers), + }), + false, + ); + assert.equal( + canSeeCommand(command, { + botAdmin: false, + permissions: perms( + PermissionFlagsBits.KickMembers, + PermissionFlagsBits.BanMembers, + ), + }), + true, + ); + assert.equal( + canSeeCommand(command, { + botAdmin: false, + permissions: perms(PermissionFlagsBits.Administrator), + }), + true, + ); + assert.equal( + canSeeCommand(command, { botAdmin: true, permissions: null }), + false, + ); +}); + +test("pages drop hidden commands and empty categories", () => { + const pages = [ + { + category: "Information", + entries: [{ line: "`/ping`", adminOnly: false }], + }, + { + category: "Management", + entries: [{ line: "`/send`", adminOnly: true }], + }, + ]; + assert.deepEqual(helpPagesFor(member, pages), [ + { category: "Information", lines: ["`/ping`"] }, + ]); + assert.equal(helpPagesFor(admin, pages).length, 2); +}); + +test("every page says why a command can be missing", () => { + const embed = buildEmbed([{ category: "Info", lines: ["`/ping`"] }], 0); + assert.match(embed.data.description, /Missing a command\?/); +}); diff --git a/wiki/Adding-a-Command.md b/wiki/Adding-a-Command.md index 8a4464e..35999a3 100644 --- a/wiki/Adding-a-Command.md +++ b/wiki/Adding-a-Command.md @@ -61,6 +61,8 @@ wording. | Bot-admin | `isAdmin(interaction.member)` inside the callback | | Anyone | Neither | +A bot-admin command also sets `adminOnly: true` on its module, so `/help` only lists it for bot-admins. + Do not invent a third path, and do not add a dispatch route that skips the single-server guard in `handleCommands.js`. See [Admin roles](Admin-Roles). diff --git a/wiki/Commands.md b/wiki/Commands.md index 01b3902..671ea20 100644 --- a/wiki/Commands.md +++ b/wiki/Commands.md @@ -2,7 +2,7 @@ Every command TriBridge answers to. Slash commands are registered **globally** but only accepted in the one Discord server the bot serves — see [Admin roles](Admin-Roles) for why. `/help` browses the same list in -Discord, one page per category. +Discord, one page per category, showing only the commands the member can run. ⛔ marks a command that needs the Discord **Administrator** permission. 🔒 marks one gated on a [bot-admin role](Admin-Roles).