From 572fb57f650e1dd52c3ae022b4dbb90e9ef0ce41 Mon Sep 17 00:00:00 2001 From: "Kkermit." <132657029+Kkkermit@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:08:01 +0000 Subject: [PATCH 1/2] fix: Checked the bot can post in a channel before posting there Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01JrFnSipitEXUhVySPKfrMR --- AGENTS.md | 10 +++- src/buttons/auditLog.ts | 5 +- src/commands/economy/lottery.command.ts | 4 +- src/commands/giveaway/giveaway.command.ts | 4 +- src/commands/info/botStatsChannel.command.ts | 4 +- src/commands/moderation/announce.command.ts | 4 +- src/commands/moderation/say.command.ts | 4 +- src/commands/settings/sticky.command.ts | 4 +- src/commands/tickets/ticket.command.ts | 6 +- src/config/permissionNames.ts | 7 +++ src/config/replies.ts | 8 +++ src/core/checks.ts | 6 +- src/core/language.ts | 5 ++ src/lib/discord/channelPick.util.ts | 12 ++-- src/lib/discord/channels.util.ts | 53 ++++++++++++++++-- tests/api/guildRoutes.test.ts | 1 + tests/api/stickyRouteHandlers.test.ts | 1 + tests/commands/announce.test.ts | 58 ++++++++++++++++++++ tests/lib/discord/channelPick.test.ts | 25 ++++++++- tests/lib/discord/channels.test.ts | 50 +++++++++++++++++ tests/lib/settings/setupChecklist.test.ts | 1 + 21 files changed, 247 insertions(+), 25 deletions(-) create mode 100644 tests/commands/announce.test.ts create mode 100644 tests/lib/discord/channels.test.ts diff --git a/AGENTS.md b/AGENTS.md index 940cb213d..2ed5e030d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -279,7 +279,7 @@ union type, so a mistyped category is a **compile error**. Adding a category the | Change a permission or cooldown gate | `src/core/checks.ts` | | Change how prefix commands parse | `src/core/prefix.ts` — the only file that knows they exist | | Build a button/select/modal | `src/lib/discord/components.util.ts` | -| Read a channel a select menu picked | `src/lib/discord/channelPick.util.ts` — one rule for "can the bot post" | +| Check the bot can post in a channel | `src/lib/discord/channels.util.ts` — `missingToPost`, the one rule for it | | Build a Components V2 message | `src/lib/discord/containers.util.ts` | | Build an embed | `src/lib/discord/embeds.util.ts` (nothing else may `new EmbedBuilder()`) | | Draw an image card | `src/lib/canvas/canvas.util.ts`, then a `*Card.util.ts` beside it | @@ -531,6 +531,14 @@ Rules: with its buttons, jobs and help articles. A command, button or event that imports a switchable feature's own `src/lib` folder without being declared under it fails `botFeatures.test.ts`. +**A command that posts where it is told checks the bot can post there first.** `botPermissions` is asked of the +whole server and `isSendable()` only of the channel's type — `'send' in this`, read out of discord.js — so a channel +the bot cannot see passed both and Discord refused the post with 50001, which reached the reader as an unexpected +failure. `requirePostable(channel, readerLanguage(interaction), [what the post needs])` refuses first, naming the +channel and each permission missing (`iNeedIn`). `missingToPost` is the one rule behind it and behind +`canPostInChannel` and the panels' `pickedChannelId`: View Channel, then Send Messages, or Send Messages in Threads for +a thread. + **A bounded option is held on every surface.** Discord refuses a slash command's value outside its `min`, `max`, `choices` or `maxLength`, but the prefix and the dashboard's runner read text, so `OptionReader` refuses it there instead, and a choice typed in any case reads as the declared value. Nothing held the prefix to `min: 1` once, so diff --git a/src/buttons/auditLog.ts b/src/buttons/auditLog.ts index bbc3a2a3c..7da823217 100644 --- a/src/buttons/auditLog.ts +++ b/src/buttons/auditLog.ts @@ -1,5 +1,6 @@ import { defineButton } from "@core/button"; import { UserFacingError } from "@core/errors"; +import { readerLanguage } from "@core/language"; import { disableAuditLog, getAuditLogConfig, setAuditLogConfig } from "@database/repositories/auditLogRepository"; import { requireSendable } from "@lib/discord"; import { @@ -53,7 +54,7 @@ export default defineButton({ const [channelId] = interaction.values; if (channelId === undefined) return; - await requireSendable(guild, channelId); + await requireSendable(guild, channelId, readerLanguage(interaction)); const fresh = saved.channelId === null && saved.enabled.length === 0 && draft.events.length === 0; await showEditor({ channelId, events: fresh ? [...AUDIT_EVENTS] : draft.events }); @@ -84,7 +85,7 @@ export default defineButton({ case "save": { if (draft.channelId === null) throw new UserFacingError("Choose a log channel first."); - await requireSendable(guild, draft.channelId); + await requireSendable(guild, draft.channelId, readerLanguage(interaction)); const enabled = collapseEnabled(draft.events); await setAuditLogConfig(guild.id, draft.channelId, enabled); diff --git a/src/commands/economy/lottery.command.ts b/src/commands/economy/lottery.command.ts index e76a7ade4..d8cb08ac7 100644 --- a/src/commands/economy/lottery.command.ts +++ b/src/commands/economy/lottery.command.ts @@ -2,6 +2,7 @@ import { PermissionFlagsBits, MessageFlags } from "discord.js"; import { strings } from "@config/strings"; import { defineCommand, inGuild, textChannelOption } from "@core/command"; import { UserFacingError } from "@core/errors"; +import { readerLanguage } from "@core/language"; import { type LotteryFrequency } from "@database/models/lottery.schema"; import { debitWallet, requireAccount } from "@database/repositories/economyRepository"; import { @@ -11,7 +12,7 @@ import { intervalFor, saveLottery, } from "@database/repositories/lotteryRepository"; -import { embed, reply, successEmbed } from "@lib/discord"; +import { embed, reply, requirePostable, successEmbed } from "@lib/discord"; import { discordTime, formatNumber } from "@lib/format"; import { recordsSettings } from "@lib/settings"; @@ -115,6 +116,7 @@ export default recordsSettings( if (!channel?.isTextBased() || !channel.isSendable()) { throw new UserFacingError("Pick a text channel I can post the results in."); } + requirePostable(channel, readerLanguage(interaction), [PermissionFlagsBits.EmbedLinks]); const frequency = (interaction.options.getString("frequency") ?? "weekly") as LotteryFrequency; const basePrizePool = interaction.options.getInteger("base-pool") ?? 0; diff --git a/src/commands/giveaway/giveaway.command.ts b/src/commands/giveaway/giveaway.command.ts index 0f21731f7..d5d57485f 100644 --- a/src/commands/giveaway/giveaway.command.ts +++ b/src/commands/giveaway/giveaway.command.ts @@ -1,7 +1,8 @@ import { PermissionFlagsBits } from "discord.js"; import { defineCommand, inGuild, textChannelOption } from "@core/command"; import { UserFacingError } from "@core/errors"; -import { reply, successEmbed } from "@lib/discord"; +import { readerLanguage } from "@core/language"; +import { reply, requirePostable, successEmbed } from "@lib/discord"; import { formatDurationLong, parseDuration } from "@lib/format"; import { deleteGiveaway, endGiveaway, rerollGiveaway, startGiveaway } from "@lib/giveaways"; @@ -39,6 +40,7 @@ export default defineCommand({ if (!channel?.isTextBased() || !("guild" in channel)) { throw new UserFacingError("Pick a text channel in this server."); } + requirePostable(channel, readerLanguage(interaction), [PermissionFlagsBits.EmbedLinks]); await startGiveaway(client, guild, { channelId: channel.id, diff --git a/src/commands/info/botStatsChannel.command.ts b/src/commands/info/botStatsChannel.command.ts index 4d08b7cd3..6d9764c11 100644 --- a/src/commands/info/botStatsChannel.command.ts +++ b/src/commands/info/botStatsChannel.command.ts @@ -1,7 +1,8 @@ import { PermissionFlagsBits } from "discord.js"; import { defineCommand, inGuild, textChannelOption } from "@core/command"; import { UserFacingError } from "@core/errors"; -import { reply, successEmbed } from "@lib/discord"; +import { readerLanguage } from "@core/language"; +import { reply, requirePostable, successEmbed } from "@lib/discord"; import { postBotStats, removeBotStats } from "@lib/info"; export default defineCommand({ @@ -27,6 +28,7 @@ export default defineCommand({ async run(interaction, client) { const channel = textChannelOption(interaction, "channel"); if (channel === null) throw new UserFacingError("Pick a text channel I can send messages in."); + requirePostable(channel, readerLanguage(interaction), [PermissionFlagsBits.EmbedLinks]); await postBotStats(client, inGuild(interaction), channel.id, interaction.user.id); diff --git a/src/commands/moderation/announce.command.ts b/src/commands/moderation/announce.command.ts index ade7abd17..68a81b9e0 100644 --- a/src/commands/moderation/announce.command.ts +++ b/src/commands/moderation/announce.command.ts @@ -1,7 +1,8 @@ import { PermissionFlagsBits } from "discord.js"; import { asMember, defineCommand, inGuild, textChannelOption } from "@core/command"; import { UserFacingError } from "@core/errors"; -import { COLOUR_CHOICES, reply, resolveColour, successEmbed } from "@lib/discord"; +import { readerLanguage } from "@core/language"; +import { COLOUR_CHOICES, reply, requirePostable, resolveColour, successEmbed } from "@lib/discord"; import { announcementMessage, mentionProblem } from "@lib/moderation"; export default defineCommand({ @@ -30,6 +31,7 @@ export default defineCommand({ if (!channel?.isTextBased() || !channel.isSendable()) { throw new UserFacingError("Pick a text channel I can send messages in."); } + requirePostable(channel, readerLanguage(interaction), [PermissionFlagsBits.EmbedLinks]); const mentions = (interaction.options.getBoolean("ping-everyone") ?? false) ? "everyone" : "none"; const problem = mentionProblem(channel, inGuild(interaction), asMember(interaction), mentions); diff --git a/src/commands/moderation/say.command.ts b/src/commands/moderation/say.command.ts index 238091b32..304ff81d7 100644 --- a/src/commands/moderation/say.command.ts +++ b/src/commands/moderation/say.command.ts @@ -2,7 +2,8 @@ import { PermissionFlagsBits } from "discord.js"; import { strings } from "@config/strings"; import { defineCommand, inTextChannel, textChannelOption } from "@core/command"; import { UserFacingError } from "@core/errors"; -import { embed, reply, successEmbed } from "@lib/discord"; +import { readerLanguage } from "@core/language"; +import { embed, reply, requirePostable, successEmbed } from "@lib/discord"; import { containsProfanity } from "@lib/moderation"; export default defineCommand({ @@ -29,6 +30,7 @@ export default defineCommand({ } const asEmbed = interaction.options.getBoolean("as-embed") ?? false; + requirePostable(target, readerLanguage(interaction), asEmbed ? [PermissionFlagsBits.EmbedLinks] : []); await target.send( asEmbed diff --git a/src/commands/settings/sticky.command.ts b/src/commands/settings/sticky.command.ts index d1acc41ac..b9a6f5c9b 100644 --- a/src/commands/settings/sticky.command.ts +++ b/src/commands/settings/sticky.command.ts @@ -1,8 +1,9 @@ import { PermissionFlagsBits } from "discord.js"; import { defineCommand, inGuild, textChannelOption } from "@core/command"; import { UserFacingError } from "@core/errors"; +import { readerLanguage } from "@core/language"; import { listSticky, removeSticky, setSticky } from "@database/repositories/stickyRepository"; -import { embed, reply, successEmbed } from "@lib/discord"; +import { embed, reply, requirePostable, successEmbed } from "@lib/discord"; import { truncate } from "@lib/format"; export default defineCommand({ @@ -46,6 +47,7 @@ export default defineCommand({ if (!channel?.isTextBased() || !channel.isSendable()) { throw new UserFacingError("Pick a text channel I can send messages in."); } + requirePostable(channel, readerLanguage(interaction), [PermissionFlagsBits.EmbedLinks]); const message = interaction.options.getString("message", true); await setSticky(guild.id, channel.id, message, interaction.options.getInteger("cap", true)); diff --git a/src/commands/tickets/ticket.command.ts b/src/commands/tickets/ticket.command.ts index 97176c3fd..a1c6a81a3 100644 --- a/src/commands/tickets/ticket.command.ts +++ b/src/commands/tickets/ticket.command.ts @@ -1,8 +1,9 @@ import { ChannelType, PermissionFlagsBits } from "discord.js"; import { channelOption, defineCommand, inGuild, roleOption, textChannelOption } from "@core/command"; import { UserFacingError } from "@core/errors"; +import { readerLanguage } from "@core/language"; import { deleteTicketSetup, getTicketSetup, saveTicketSetup } from "@database/repositories/ticketRepository"; -import { embed, reply, successEmbed } from "@lib/discord"; +import { embed, reply, requirePostable, successEmbed } from "@lib/discord"; import { normaliseTicketSetup, publishTicketPanel } from "@lib/tickets"; export default defineCommand({ @@ -37,6 +38,8 @@ export default defineCommand({ const panelChannel = textChannelOption(interaction, "panel-channel"); if (!panelChannel) throw new UserFacingError("Pick a channel I can post the panel in."); + const language = readerLanguage(interaction); + requirePostable(panelChannel, language, [PermissionFlagsBits.EmbedLinks]); const category = channelOption(interaction, "category"); if (category?.type !== ChannelType.GuildCategory) { @@ -45,6 +48,7 @@ export default defineCommand({ const transcripts = textChannelOption(interaction, "transcripts"); if (!transcripts) throw new UserFacingError("Pick a channel I can send transcripts to."); + requirePostable(transcripts, language, [PermissionFlagsBits.EmbedLinks, PermissionFlagsBits.AttachFiles]); const staffRole = roleOption(interaction, "staff-role", true); if (!staffRole) throw new UserFacingError("Pick the role that should handle tickets."); diff --git a/src/config/permissionNames.ts b/src/config/permissionNames.ts index 194876148..0208a8917 100644 --- a/src/config/permissionNames.ts +++ b/src/config/permissionNames.ts @@ -13,6 +13,7 @@ export const PERMISSION_NAMES: Record> = { nsfwOnly: "Dieser Befehl funktioniert nur in altersbeschränkten Kanälen.", youNeed: "Dir fehlen diese Berechtigungen: {permissions}", iNeed: "Mir fehlen diese Berechtigungen: {permissions}", + iNeedIn: "In {channel} fehlen mir diese Berechtigungen: {permissions}", paused: "{bot} ist gerade pausiert. Der Bot-Betreiber kann ihn im Dashboard fortsetzen.", offHere: "Dieser Befehl ist auf diesem Server ausgeschaltet. Jeder mit „Server verwalten“ kann ihn wieder einschalten.", @@ -82,6 +84,7 @@ export const REPLIES: Record> = { nsfwOnly: "Este comando solo funciona en canales con restricción de edad.", youNeed: "Necesitas estos permisos: {permissions}", iNeed: "Necesito estos permisos: {permissions}", + iNeedIn: "Necesito estos permisos en {channel}: {permissions}", paused: "{bot} está en pausa ahora mismo. El dueño del bot puede reanudarlo desde el panel.", offHere: "Ese comando está desactivado en este servidor. Cualquiera con Gestionar servidor puede volver a activarlo.", @@ -118,6 +121,7 @@ export const REPLIES: Record> = { nsfwOnly: "Cette commande ne fonctionne que dans les salons soumis à une limite d’âge.", youNeed: "Il vous faut ces permissions : {permissions}", iNeed: "Il me faut ces permissions : {permissions}", + iNeedIn: "Il me faut ces permissions dans {channel} : {permissions}", paused: "{bot} est en pause pour le moment. Le propriétaire du bot peut le relancer depuis le tableau de bord.", offHere: "Cette commande est désactivée sur ce serveur. Toute personne ayant Gérer le serveur peut la réactiver.", featureOff: "Le propriétaire de {bot} a désactivé cette partie du bot, elle ne fonctionne donc sur aucun serveur.", @@ -153,6 +157,7 @@ export const REPLIES: Record> = { nsfwOnly: "Questo comando funziona solo nei canali con limite di età.", youNeed: "Ti servono questi permessi: {permissions}", iNeed: "Mi servono questi permessi: {permissions}", + iNeedIn: "Mi servono questi permessi in {channel}: {permissions}", paused: "{bot} è in pausa al momento. Il proprietario del bot può riprenderlo dalla dashboard.", offHere: "Quel comando è disattivato in questo server. Chiunque abbia Gestisci server può riattivarlo.", featureOff: "Il proprietario di {bot} ha disattivato questa parte del bot, quindi non funziona in nessun server.", @@ -188,6 +193,7 @@ export const REPLIES: Record> = { nsfwOnly: "Эта команда работает только в каналах с возрастным ограничением.", youNeed: "Вам нужны эти права: {permissions}", iNeed: "Мне нужны эти права: {permissions}", + iNeedIn: "Мне нужны эти права в {channel}: {permissions}", paused: "{bot} сейчас на паузе. Владелец бота может возобновить его в панели.", offHere: "Эта команда выключена на этом сервере. Любой с правом «Управлять сервером» может включить её снова.", featureOff: "Владелец {bot} выключил эту часть бота, поэтому она не работает ни на одном сервере.", @@ -220,6 +226,7 @@ export const REPLIES: Record> = { nsfwOnly: "Deze opdracht werkt alleen in kanalen met een leeftijdsgrens.", youNeed: "Je hebt deze rechten nodig: {permissions}", iNeed: "Ik heb deze rechten nodig: {permissions}", + iNeedIn: "Ik heb deze rechten nodig in {channel}: {permissions}", paused: "{bot} is nu gepauzeerd. De eigenaar van de bot kan hem in het dashboard hervatten.", offHere: "Die opdracht staat uit in deze server. Iedereen met Server beheren kan hem weer aanzetten.", featureOff: "De eigenaar van {bot} heeft dit deel van de bot uitgezet, dus het werkt in geen enkele server.", @@ -254,6 +261,7 @@ export const REPLIES: Record> = { nsfwOnly: "このコマンドは年齢制限のあるチャンネルでのみ使えます。", youNeed: "次の権限が必要です:{permissions}", iNeed: "ボットに次の権限が必要です:{permissions}", + iNeedIn: "{channel} でボットに次の権限が必要です:{permissions}", paused: "{bot} は現在一時停止中です。ボットのオーナーがダッシュボードから再開できます。", offHere: "このコマンドはこのサーバーでオフになっています。「サーバー管理」の権限があれば誰でもオンに戻せます。", featureOff: "{bot}のオーナーがボットのこの機能をオフにしているため、どのサーバーでも使えません。", diff --git a/src/core/checks.ts b/src/core/checks.ts index 6c17aa7bf..fb8188d42 100644 --- a/src/core/checks.ts +++ b/src/core/checks.ts @@ -4,7 +4,7 @@ import { theme } from "@config/theme"; import { botName } from "@core/brand"; import { type TestifyClient } from "@core/client"; import { type Command, type CommandInput, type Subcommand } from "@core/command"; -import { allOf, durationIn, eitherOf, permissionName, readerLanguage, say } from "@core/language"; +import { durationIn, eitherOf, permissionList, readerLanguage, say } from "@core/language"; import { findBlacklistEntry } from "@database/repositories/blacklistRepository"; import { disabledGlobally, disabledInGuild } from "@database/repositories/commandToggleRepository"; import { getMusicSettings } from "@database/repositories/musicSettingsRepository"; @@ -100,9 +100,7 @@ export async function runChecks( } function lacking(language: BotLanguage, who: "youNeed" | "iNeed", missing: string[]): string { - return say(language, who, { - permissions: allOf(language, [...new Set(missing.map((flag) => `\`${permissionName(language, flag)}\``))]), - }); + return say(language, who, { permissions: permissionList(language, missing) }); } async function ownerNames(client: TestifyClient): Promise { diff --git a/src/core/language.ts b/src/core/language.ts index 7578d4f5d..ad0123b07 100644 --- a/src/core/language.ts +++ b/src/core/language.ts @@ -45,6 +45,11 @@ export function permissionName(language: BotLanguage, flag: string): string { return PERMISSION_NAMES[language][current as PermissionsString] ?? humanisePermission(current); } +/** Permission flags as a refusal lists them: each named in the reader's language, once, in code spans. */ +export function permissionList(language: BotLanguage, flags: readonly string[]): string { + return allOf(language, [...new Set(flags.map((flag) => `\`${permissionName(language, flag)}\``))]); +} + const UNITS = [ ["day", DAY_MS], ["hour", HOUR_MS], diff --git a/src/lib/discord/channelPick.util.ts b/src/lib/discord/channelPick.util.ts index 779107494..34f1c4fe5 100644 --- a/src/lib/discord/channelPick.util.ts +++ b/src/lib/discord/channelPick.util.ts @@ -1,6 +1,9 @@ import { type Guild } from "discord.js"; +import { type BotLanguage } from "@config/languages"; import { type ComponentInteraction } from "@core/button"; import { UserFacingError } from "@core/errors"; +import { readerLanguage } from "@core/language"; +import { requirePostable } from "@lib/discord/channels.util"; /** The channel a select menu picked, refusing one the bot cannot post in; null when there is nothing to do. */ export async function pickedChannelId(interaction: ComponentInteraction, guild: Guild): Promise { @@ -9,15 +12,16 @@ export async function pickedChannelId(interaction: ComponentInteraction, guild: const [channelId] = interaction.values; if (channelId === undefined) return null; - await requireSendable(guild, channelId); + await requireSendable(guild, channelId, readerLanguage(interaction)); return channelId; } -/** Worth re-checking on save as well as on pick: a channel can be deleted between the two. */ -export async function requireSendable(guild: Guild, channelId: string): Promise { +/** Worth re-checking on save as well as on pick: a channel can be deleted, or the bot's access to it taken, between the two. */ +export async function requireSendable(guild: Guild, channelId: string, language: BotLanguage = "en"): Promise { const channel = await guild.channels.fetch(channelId).catch(() => null); - if (channel?.isSendable() !== true) { + if (channel?.isSendable() !== true || !channel.isTextBased()) { throw new UserFacingError("I cannot post in that channel. Pick one I can send messages to."); } + requirePostable(channel, language); } diff --git a/src/lib/discord/channels.util.ts b/src/lib/discord/channels.util.ts index 82bfe653e..6f9d29ce8 100644 --- a/src/lib/discord/channels.util.ts +++ b/src/lib/discord/channels.util.ts @@ -1,4 +1,35 @@ -import { type Guild, type GuildBasedChannel, type GuildMember, PermissionFlagsBits } from "discord.js"; +import { + type Guild, + type GuildBasedChannel, + type GuildMember, + type GuildTextBasedChannel, + PermissionFlagsBits, + type PermissionResolvable, + PermissionsBitField, + type PermissionsString, +} from "discord.js"; +import { type BotLanguage } from "@config/languages"; +import { UserFacingError } from "@core/errors"; +import { permissionList, say } from "@core/language"; + +/** View Channel, then Send Messages, or Send Messages in Threads for a thread, which Discord asks there instead. */ +function postingNeeds(channel: GuildBasedChannel, also: readonly PermissionResolvable[]): PermissionResolvable[] { + return [ + PermissionFlagsBits.ViewChannel, + channel.isThread() ? PermissionFlagsBits.SendMessagesInThreads : PermissionFlagsBits.SendMessages, + ...also, + ]; +} + +export function missingToPost( + channel: GuildBasedChannel, + also: readonly PermissionResolvable[] = [], +): PermissionsString[] { + const needs = postingNeeds(channel, also); + const me = channel.guild.members.me; + + return me === null ? new PermissionsBitField(needs).toArray() : channel.permissionsFor(me).missing(needs); +} /** Whether the bot could actually post in a channel, which is what lets a picker grey one out before a save. */ export function canPostIn(guild: Guild, channelId: string): boolean { @@ -10,11 +41,21 @@ export function canPostIn(guild: Guild, channelId: string): boolean { export function canPostInChannel(guild: Guild, channel: GuildBasedChannel): boolean { const me = guild.members.me; - return ( - me !== null && - channel.isTextBased() && - channel.permissionsFor(me).has([PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages]) - ); + return me !== null && channel.isTextBased() && channel.permissionsFor(me).has(postingNeeds(channel, [])); +} + +/** Refuses, naming what is missing, before a send Discord would refuse with "Missing Access". */ +export function requirePostable( + channel: GuildTextBasedChannel, + language: BotLanguage, + also: readonly PermissionResolvable[] = [], +): void { + const missing = missingToPost(channel, also); + if (missing.length > 0) { + throw new UserFacingError( + say(language, "iNeedIn", { channel: channel.toString(), permissions: permissionList(language, missing) }), + ); + } } /** Discord hides a channel from whoever cannot view it; a bot owner, who need not be a member, is `undefined` here. */ diff --git a/tests/api/guildRoutes.test.ts b/tests/api/guildRoutes.test.ts index 9b1fa24a6..7118db365 100644 --- a/tests/api/guildRoutes.test.ts +++ b/tests/api/guildRoutes.test.ts @@ -177,6 +177,7 @@ function fakeGuild(options: Options = {}) { position, parentId: type === ChannelType.GuildText ? "4" : null, isTextBased: () => type === ChannelType.GuildText || type === ChannelType.GuildAnnouncement, + isThread: () => false, permissionsFor: (who: unknown) => ({ has: () => (who === reader ? !options.hiddenFromReader?.has(id) : (options.canSendIn?.has(id) ?? true)), }), diff --git a/tests/api/stickyRouteHandlers.test.ts b/tests/api/stickyRouteHandlers.test.ts index 855ebbfb0..609872eb8 100644 --- a/tests/api/stickyRouteHandlers.test.ts +++ b/tests/api/stickyRouteHandlers.test.ts @@ -34,6 +34,7 @@ function app(): Hono { id: CHANNEL, type: ChannelType.GuildText, isTextBased: () => true, + isThread: () => false, permissionsFor: () => ({ has: () => true }), }; const guild = { diff --git a/tests/commands/announce.test.ts b/tests/commands/announce.test.ts new file mode 100644 index 000000000..57494261f --- /dev/null +++ b/tests/commands/announce.test.ts @@ -0,0 +1,58 @@ +import { PermissionFlagsBits, PermissionsBitField, type TextChannel } from "discord.js"; +import announce from "@commands/moderation/announce.command"; +import { dispatch } from "@core/command"; +import { UserFacingError } from "@core/errors"; +import { + CHANNEL_ID, + createMockChannel, + createMockClient, + createMockGuild, + createMockInteraction, + createMockMember, + mockCollection, +} from "@tests/helpers/mocks"; + +function announcing(granted: bigint[]): { channel: TextChannel; run: () => Promise } { + const guild = createMockGuild(); + const channel = createMockChannel({ + guild, + isThread: jest.fn(() => false), + permissionsFor: jest.fn(() => new PermissionsBitField(granted)), + toString: () => `<#${CHANNEL_ID}>`, + } as never); + const interaction = createMockInteraction({ + options: { channel, title: "Maintenance", description: "Back at six." }, + overrides: { + guild: { ...guild, channels: { cache: mockCollection([[CHANNEL_ID, channel]]) } }, + member: createMockMember({ guild }), + } as never, + }); + + return { channel, run: () => dispatch(interaction, announce, createMockClient()) }; +} + +describe("/announce", () => { + /** Discord refused the post with 50001 Missing Access, which reached the reader as an unexpected failure. */ + it("refuses a channel the bot cannot see before trying to post in it", async () => { + const { channel, run } = announcing([]); + + await expect(run()).rejects.toThrow( + new UserFacingError( + `I need these permissions in <#${CHANNEL_ID}>: \`view channel\`, \`send messages\` and \`embed links\``, + ), + ); + expect(channel.send).not.toHaveBeenCalled(); + }); + + it("posts where the bot can see, send and embed", async () => { + const { channel, run } = announcing([ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.EmbedLinks, + ]); + + await run(); + + expect(channel.send).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/lib/discord/channelPick.test.ts b/tests/lib/discord/channelPick.test.ts index 759b2d45a..86383baac 100644 --- a/tests/lib/discord/channelPick.test.ts +++ b/tests/lib/discord/channelPick.test.ts @@ -1,4 +1,4 @@ -import { type Guild } from "discord.js"; +import { type Guild, PermissionFlagsBits, PermissionsBitField } from "discord.js"; import { type ComponentInteraction } from "@core/button"; import { UserFacingError } from "@core/errors"; import { pickedChannelId, requireSendable } from "@lib/discord/channelPick.util"; @@ -15,6 +15,18 @@ function channelSelect(values: string[]): ComponentInteraction { const sendable = { isSendable: () => true }; +/** A channel of a type that can take messages, in which the bot holds only `granted`. */ +function channelGranting(granted: bigint[]): unknown { + return { + ...sendable, + isTextBased: () => true, + isThread: () => false, + guild: { members: { me: { id: "bot" } } }, + permissionsFor: () => new PermissionsBitField(granted), + toString: () => "<#123>", + }; +} + describe("pickedChannelId", () => { /** A handler treats null as nothing to do, so the wrong component type must not throw. */ it("ignores an interaction that is not a channel select", async () => { @@ -28,6 +40,17 @@ describe("pickedChannelId", () => { await expect(pickedChannelId(channelSelect(["123"]), guild)).rejects.toThrow(UserFacingError); }); + + /** A text channel the bot cannot see used to pass, and every post the panel set up there failed later. */ + it("refuses a text channel the bot cannot see or send in, saying which", async () => { + const hidden = guildWith(channelGranting([])); + const open = guildWith(channelGranting([PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages])); + + await expect(pickedChannelId(channelSelect(["123"]), hidden)).rejects.toThrow( + "I need these permissions in <#123>: `view channel` and `send messages`", + ); + await expect(pickedChannelId(channelSelect(["123"]), open)).resolves.toBe("123"); + }); }); describe("requireSendable", () => { diff --git a/tests/lib/discord/channels.test.ts b/tests/lib/discord/channels.test.ts new file mode 100644 index 000000000..88a3b989e --- /dev/null +++ b/tests/lib/discord/channels.test.ts @@ -0,0 +1,50 @@ +import { type GuildTextBasedChannel, PermissionFlagsBits, PermissionsBitField } from "discord.js"; +import { UserFacingError } from "@core/errors"; +import { missingToPost, requirePostable } from "@lib/discord/channels.util"; + +function channelWhere(granted: bigint[], { thread = false } = {}): GuildTextBasedChannel { + return { + isThread: () => thread, + isTextBased: () => true, + guild: { members: { me: { id: "bot" } } }, + permissionsFor: () => new PermissionsBitField(granted), + toString: () => "<#500000000000000005>", + } as unknown as GuildTextBasedChannel; +} + +describe("missingToPost", () => { + /** `isSendable()` only asks the channel's type, so a channel the bot cannot see passed it and Discord answered 50001. */ + it("names View Channel for a channel the bot cannot see", () => { + expect(missingToPost(channelWhere([]))).toEqual(["ViewChannel", "SendMessages"]); + }); + + /** Discord asks Send Messages in Threads of a thread, and a thread that only had Send Messages was refused. */ + it("asks a thread for Send Messages in Threads instead of Send Messages", () => { + const thread = channelWhere([PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages], { thread: true }); + + expect(missingToPost(thread)).toEqual(["SendMessagesInThreads"]); + }); + + it("adds what the post itself needs, and nothing for an administrator", () => { + const canPost = channelWhere([PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages]); + + expect(missingToPost(canPost, [PermissionFlagsBits.EmbedLinks])).toEqual(["EmbedLinks"]); + expect(missingToPost(channelWhere([PermissionFlagsBits.Administrator]), [PermissionFlagsBits.EmbedLinks])).toEqual( + [], + ); + }); +}); + +describe("requirePostable", () => { + it("refuses in the reader's language, naming the channel and each permission missing", () => { + const viewOnly = channelWhere([PermissionFlagsBits.ViewChannel]); + + expect(() => { + requirePostable(viewOnly, "de", [PermissionFlagsBits.EmbedLinks]); + }).toThrow( + new UserFacingError( + "In <#500000000000000005> fehlen mir diese Berechtigungen: `Nachrichten senden` und `Links einbetten`", + ), + ); + }); +}); diff --git a/tests/lib/settings/setupChecklist.test.ts b/tests/lib/settings/setupChecklist.test.ts index c01dbb0b3..fd0ff45b6 100644 --- a/tests/lib/settings/setupChecklist.test.ts +++ b/tests/lib/settings/setupChecklist.test.ts @@ -44,6 +44,7 @@ function channel(id: string, name: string, rawPosition: number, options: Options type, memberCan: member, isTextBased: () => true, + isThread: () => false, permissionsFor: (who: unknown) => ({ has: () => (who === EVERYONE ? everyone : bot) }), }; } From 2a06c1883b310817cbc56adb489244f5d73233a3 Mon Sep 17 00:00:00 2001 From: "Kkermit." <132657029+Kkkermit@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:42:06 +0000 Subject: [PATCH 2/2] chore: Bumped the version to 2.4.3 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01JrFnSipitEXUhVySPKfrMR --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- dashboard/package.json | 2 +- package-lock.json | 8 ++++---- package.json | 2 +- shared/package.json | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 68db64bab..4ef56e438 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -50,7 +50,7 @@ body: id: version attributes: label: Testify version or commit - placeholder: 2.4.2 + placeholder: 2.4.3 validations: required: true diff --git a/dashboard/package.json b/dashboard/package.json index c95123d15..8ba7ade7b 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@testify/dashboard", - "version": "2.4.2", + "version": "2.4.3", "private": true, "type": "module", "scripts": { diff --git a/package-lock.json b/package-lock.json index e81d7d593..fec0418e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "testify", - "version": "2.4.2", + "version": "2.4.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "testify", - "version": "2.4.2", + "version": "2.4.3", "license": "MIT", "workspaces": [ "shared", @@ -70,7 +70,7 @@ }, "dashboard": { "name": "@testify/dashboard", - "version": "2.4.2", + "version": "2.4.3", "dependencies": { "@tanstack/react-query": "5.107.1", "@testify/shared": "*", @@ -14473,7 +14473,7 @@ }, "shared": { "name": "@testify/shared", - "version": "2.4.2", + "version": "2.4.3", "license": "MIT", "dependencies": { "zod": "4.6.7" diff --git a/package.json b/package.json index aaad03203..7f70f53f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "testify", - "version": "2.4.2", + "version": "2.4.3", "description": "Self-hosted, all-in-one Discord bot in TypeScript: every command works as slash and prefix, with a web dashboard", "main": "dist/index.js", "license": "MIT", diff --git a/shared/package.json b/shared/package.json index b4d2f493e..c37865c2b 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@testify/shared", - "version": "2.4.2", + "version": "2.4.3", "private": true, "description": "Types and zod schemas shared by the bot's API and the dashboard.", "license": "MIT",