Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ body:
id: version
attributes:
label: Testify version or commit
placeholder: 2.4.2
placeholder: 2.4.3
validations:
required: true

Expand Down
10 changes: 9 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion dashboard/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@testify/dashboard",
"version": "2.4.2",
"version": "2.4.3",
"private": true,
"type": "module",
"scripts": {
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion shared/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
5 changes: 3 additions & 2 deletions src/buttons/auditLog.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion src/commands/economy/lottery.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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";

Expand Down Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion src/commands/giveaway/giveaway.command.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion src/commands/info/botStatsChannel.command.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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);

Expand Down
4 changes: 3 additions & 1 deletion src/commands/moderation/announce.command.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion src/commands/moderation/say.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/commands/settings/sticky.command.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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));
Expand Down
6 changes: 5 additions & 1 deletion src/commands/tickets/ticket.command.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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) {
Expand All @@ -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.");
Expand Down
7 changes: 7 additions & 0 deletions src/config/permissionNames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const PERMISSION_NAMES: Record<TranslatedLanguage, Partial<Record<Permiss
BanMembers: "Mitglieder bannen",
KickMembers: "Mitglieder kicken",
SendMessages: "Nachrichten senden",
SendMessagesInThreads: "Nachrichten in Threads senden",
EmbedLinks: "Links einbetten",
AttachFiles: "Dateien anhängen",
ManageWebhooks: "WebHooks verwalten",
Expand All @@ -37,6 +38,7 @@ export const PERMISSION_NAMES: Record<TranslatedLanguage, Partial<Record<Permiss
BanMembers: "Banear miembros",
KickMembers: "Expulsar miembros",
SendMessages: "Enviar mensajes",
SendMessagesInThreads: "Enviar mensajes en hilos",
EmbedLinks: "Insertar enlaces",
AttachFiles: "Adjuntar archivos",
ManageWebhooks: "Gestionar webhooks",
Expand All @@ -61,6 +63,7 @@ export const PERMISSION_NAMES: Record<TranslatedLanguage, Partial<Record<Permiss
BanMembers: "Bannir des membres",
KickMembers: "Expulser des membres",
SendMessages: "Envoyer des messages",
SendMessagesInThreads: "Envoyer des messages dans les fils",
EmbedLinks: "Intégrer des liens",
AttachFiles: "Joindre des fichiers",
ManageWebhooks: "Gérer les webhooks",
Expand All @@ -85,6 +88,7 @@ export const PERMISSION_NAMES: Record<TranslatedLanguage, Partial<Record<Permiss
BanMembers: "Bandisci membri",
KickMembers: "Espelli membri",
SendMessages: "Invia messaggi",
SendMessagesInThreads: "Invia messaggi nei thread",
EmbedLinks: "Incorpora link",
AttachFiles: "Allega file",
ManageWebhooks: "Gestisci webhook",
Expand All @@ -109,6 +113,7 @@ export const PERMISSION_NAMES: Record<TranslatedLanguage, Partial<Record<Permiss
BanMembers: "Банить участников",
KickMembers: "Выгонять участников",
SendMessages: "Отправлять сообщения",
SendMessagesInThreads: "Отправлять сообщения в ветках",
EmbedLinks: "Встраивать ссылки",
AttachFiles: "Прикреплять файлы",
ManageWebhooks: "Управлять вебхуками",
Expand All @@ -133,6 +138,7 @@ export const PERMISSION_NAMES: Record<TranslatedLanguage, Partial<Record<Permiss
BanMembers: "Leden verbannen",
KickMembers: "Leden verwijderen",
SendMessages: "Berichten versturen",
SendMessagesInThreads: "Berichten versturen in threads",
EmbedLinks: "Links insluiten",
AttachFiles: "Bestanden bijvoegen",
ManageWebhooks: "Webhooks beheren",
Expand All @@ -157,6 +163,7 @@ export const PERMISSION_NAMES: Record<TranslatedLanguage, Partial<Record<Permiss
BanMembers: "メンバーをBAN",
KickMembers: "メンバーをキック",
SendMessages: "メッセージを送信",
SendMessagesInThreads: "スレッドでメッセージを送信",
EmbedLinks: "埋め込みリンク",
AttachFiles: "ファイルを添付",
ManageWebhooks: "ウェブフックの管理",
Expand Down
Loading
Loading