From f852d89960392b9646797ce9367f4318466423e2 Mon Sep 17 00:00:00 2001 From: Sayrix <43046854+Sayrix@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:44:09 +0200 Subject: [PATCH] feat(tickets): add temporary and lifetime ticket bans --- .github/workflows/builder.yml | 3 + README.md | 14 +- i18n/en/index.ts | 20 ++ i18n/fr/index.ts | 19 ++ i18n/i18n-types.ts | 130 +++++++++++ package.json | 3 +- scripts/verify-migrate-v3.mjs | 2 + scripts/verify-ticketban.mjs | 224 +++++++++++++++++++ src/db/schema.ts | 16 +- src/features/commands/ticketban/command.ts | 70 ++++++ src/features/commands/ticketunban/command.ts | 56 +++++ src/features/tickets/bans.ts | 116 ++++++++++ src/features/tickets/ticket-workflow.ts | 20 ++ src/migrate-v3-db.ts | 15 +- 14 files changed, 704 insertions(+), 4 deletions(-) create mode 100644 scripts/verify-ticketban.mjs create mode 100644 src/features/commands/ticketban/command.ts create mode 100644 src/features/commands/ticketunban/command.ts create mode 100644 src/features/tickets/bans.ts diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index fc8616ca..ab160467 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -39,6 +39,9 @@ jobs: - name: Generate i18n files run: bun run i18n + - name: Check ticket bans with an isolated database + run: bun run ticketban:test + - name: Run typecheck, biome, drizzle, and build checks shell: bash run: | diff --git a/README.md b/README.md index 4ff500ef..9b8c37cc 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,18 @@ Ticket Bot is a open-source Discord bot that allows you to easily manage support The documentation is available [here](https://github.com/Sayrix/Ticket-Bot/wiki) +### Ticket bans + +Members with the **Ban Members** or **Administrator** permission can prevent a user from opening any ticket type: + +- `/ticketban user:@user duration:7d` bans them for seven days. Durations accept `s`, `m`, `h`, `d`, and `w`, including combinations such as `1d12h`. +- `/ticketban user:@user` bans them for life. You can also set `duration:lifetime` or `duration:permanent` explicitly. +- `/ticketunban user:@user` lifts a ticket ban early. + +Running `/ticketban` again replaces the user's current ban. Bans persist across restarts, and timed bans expire automatically, including while the bot is offline. Existing tickets and invitations are unaffected. + +Before starting this version, run `bun run drizzle:push` to add the `ticket_bans` table to your bot's database. + ## πŸ’¬ Discord Ask questions and get support on our [Discord server](https://discord.gg/VasYV6MEJy). @@ -47,4 +59,4 @@ to end users interacting with the bot (such as bot embeds, the bot's software is powered by Ticket-Bot, including a link to the original project repository or to its website. -This notice must not be removed, obscured, or replaced. \ No newline at end of file +This notice must not be removed, obscured, or replaced. diff --git a/i18n/en/index.ts b/i18n/en/index.ts index 103fef81..c7898df8 100644 --- a/i18n/en/index.ts +++ b/i18n/en/index.ts @@ -14,6 +14,24 @@ const en: BaseTranslation = { } }, commands: { + ticketban: { + description: "Ban a user from opening tickets", + options: { + user: { description: "The user whose ticket ban to manage" }, + duration: { description: "Duration (e.g. 30m, 2h, 7d, 1d12h); omit or use lifetime for a lifetime ban" } + }, + not_allowed: "You need the Ban Members permission to manage ticket bans in this server.", + choose_user: "Choose a user whose ticket ban to manage.", + invalid_duration: "Enter a positive duration using s, m, h, d, or w (e.g. 30m, 7d, 1d12h), or lifetime.", + success_lifetime: "<@{userId:string}> is banned from opening tickets for life.", + success_until: + "<@{userId:string}> is banned from opening tickets until ()." + }, + ticketunban: { + description: "Lift a user's ticket ban", + success: "<@{userId:string}> can open tickets again.", + not_banned: "<@{userId:string}> has no active ticket ban." + }, add: { description: "Add someone to the current ticket", options: { @@ -107,6 +125,8 @@ const en: BaseTranslation = { select_placeholder: "Select a ticket type" }, open: { + banned_lifetime: "You are banned from opening tickets for life.", + banned_until: "You are banned from opening tickets until ().", not_allowed_type: "You are not allowed to create that ticket type.", unavailable_type: "That ticket type is not available from this panel.", max_open_reached: "You already have the maximum number of open tickets ({limit:number}).", diff --git a/i18n/fr/index.ts b/i18n/fr/index.ts index 9642c31d..da51df74 100644 --- a/i18n/fr/index.ts +++ b/i18n/fr/index.ts @@ -14,6 +14,23 @@ const fr: Translation = { } }, commands: { + ticketban: { + description: "Bannir un utilisateur de l'ouverture de tickets", + options: { + user: { description: "L'utilisateur dont vous souhaitez gΓ©rer le bannissement des tickets" }, + duration: { description: "DurΓ©e (ex. 30m, 2h, 7d, 1d12h) ; omettez ou utilisez lifetime pour un bannissement Γ  vie" } + }, + not_allowed: "Vous devez avoir la permission Bannir des membres pour gΓ©rer les bannissements des tickets sur ce serveur.", + choose_user: "Choisissez un utilisateur dont vous souhaitez gΓ©rer le bannissement des tickets.", + invalid_duration: "Indiquez une durΓ©e positive avec s, m, h, d ou w (ex. 30m, 7d, 1d12h), ou lifetime.", + success_lifetime: "<@{userId}> ne peut plus ouvrir de tickets Γ  vie.", + success_until: "<@{userId}> ne peut plus ouvrir de tickets jusqu'au ()." + }, + ticketunban: { + description: "Lever le bannissement des tickets d'un utilisateur", + success: "<@{userId}> peut Γ  nouveau ouvrir des tickets.", + not_banned: "<@{userId}> n'a aucun bannissement des tickets actif." + }, add: { description: "Ajouter quelqu'un au ticket actuel", options: { @@ -107,6 +124,8 @@ const fr: Translation = { select_placeholder: "SΓ©lectionner un type de ticket" }, open: { + banned_lifetime: "Vous ne pouvez plus ouvrir de tickets Γ  vie.", + banned_until: "Vous ne pouvez plus ouvrir de tickets jusqu'au ().", not_allowed_type: "Vous n'Γͺtes pas autorisΓ© Γ  crΓ©er ce type de ticket.", unavailable_type: "Ce type de ticket n'est pas disponible depuis ce panneau.", max_open_reached: "Vous avez dΓ©jΓ  atteint le nombre maximum de tickets ouverts ({limit}).", diff --git a/i18n/i18n-types.ts b/i18n/i18n-types.ts index 2ba81c8c..29d4ff6a 100644 --- a/i18n/i18n-types.ts +++ b/i18n/i18n-types.ts @@ -45,6 +45,65 @@ type RootTranslation = { }; }; commands: { + ticketban: { + /** + * B​a​n​ ​a​ ​u​s​e​r​ ​f​r​o​m​ ​o​p​e​n​i​n​g​ ​t​i​c​k​e​t​s + */ + description: string; + options: { + user: { + /** + * T​h​e​ ​u​s​e​r​ ​w​h​o​s​e​ ​t​i​c​k​e​t​ ​b​a​n​ ​t​o​ ​m​a​n​a​g​e + */ + description: string; + }; + duration: { + /** + * D​u​r​a​t​i​o​n​ ​(​e​.​g​.​ ​3​0​m​,​ ​2​h​,​ ​7​d​,​ ​1​d​1​2​h​)​;​ ​o​m​i​t​ ​o​r​ ​u​s​e​ ​l​i​f​e​t​i​m​e​ ​f​o​r​ ​a​ ​l​i​f​e​t​i​m​e​ ​b​a​n + */ + description: string; + }; + }; + /** + * Y​o​u​ ​n​e​e​d​ ​t​h​e​ ​B​a​n​ ​M​e​m​b​e​r​s​ ​p​e​r​m​i​s​s​i​o​n​ ​t​o​ ​m​a​n​a​g​e​ ​t​i​c​k​e​t​ ​b​a​n​s​ ​i​n​ ​t​h​i​s​ ​s​e​r​v​e​r​. + */ + not_allowed: string; + /** + * C​h​o​o​s​e​ ​a​ ​u​s​e​r​ ​w​h​o​s​e​ ​t​i​c​k​e​t​ ​b​a​n​ ​t​o​ ​m​a​n​a​g​e​. + */ + choose_user: string; + /** + * E​n​t​e​r​ ​a​ ​p​o​s​i​t​i​v​e​ ​d​u​r​a​t​i​o​n​ ​u​s​i​n​g​ ​s​,​ ​m​,​ ​h​,​ ​d​,​ ​o​r​ ​w​ ​(​e​.​g​.​ ​3​0​m​,​ ​7​d​,​ ​1​d​1​2​h​)​,​ ​o​r​ ​l​i​f​e​t​i​m​e​. + */ + invalid_duration: string; + /** + * <​@​{​u​s​e​r​I​d​}​>​ ​i​s​ ​b​a​n​n​e​d​ ​f​r​o​m​ ​o​p​e​n​i​n​g​ ​t​i​c​k​e​t​s​ ​f​o​r​ ​l​i​f​e​. + * @param {string} userId + */ + success_lifetime: RequiredParams<"userId">; + /** + * <​@​{​u​s​e​r​I​d​}​>​ ​i​s​ ​b​a​n​n​e​d​ ​f​r​o​m​ ​o​p​e​n​i​n​g​ ​t​i​c​k​e​t​s​ ​u​n​t​i​l​ ​<​t​:​{​t​i​m​e​s​t​a​m​p​}​:​F​>​ ​(​<​t​:​{​t​i​m​e​s​t​a​m​p​}​:​R​>​)​. + * @param {number} timestamp + * @param {string} userId + */ + success_until: RequiredParams<"timestamp" | "timestamp" | "userId">; + }; + ticketunban: { + /** + * L​i​f​t​ ​a​ ​u​s​e​r​'​s​ ​t​i​c​k​e​t​ ​b​a​n + */ + description: string; + /** + * <​@​{​u​s​e​r​I​d​}​>​ ​c​a​n​ ​o​p​e​n​ ​t​i​c​k​e​t​s​ ​a​g​a​i​n​. + * @param {string} userId + */ + success: RequiredParams<"userId">; + /** + * <​@​{​u​s​e​r​I​d​}​>​ ​h​a​s​ ​n​o​ ​a​c​t​i​v​e​ ​t​i​c​k​e​t​ ​b​a​n​. + * @param {string} userId + */ + not_banned: RequiredParams<"userId">; + }; add: { /** * A​d​d​ ​s​o​m​e​o​n​e​ ​t​o​ ​t​h​e​ ​c​u​r​r​e​n​t​ ​t​i​c​k​e​t @@ -304,6 +363,15 @@ type RootTranslation = { select_placeholder: string; }; open: { + /** + * Y​o​u​ ​a​r​e​ ​b​a​n​n​e​d​ ​f​r​o​m​ ​o​p​e​n​i​n​g​ ​t​i​c​k​e​t​s​ ​f​o​r​ ​l​i​f​e​. + */ + banned_lifetime: string; + /** + * Y​o​u​ ​a​r​e​ ​b​a​n​n​e​d​ ​f​r​o​m​ ​o​p​e​n​i​n​g​ ​t​i​c​k​e​t​s​ ​u​n​t​i​l​ ​<​t​:​{​t​i​m​e​s​t​a​m​p​}​:​F​>​ ​(​<​t​:​{​t​i​m​e​s​t​a​m​p​}​:​R​>​)​. + * @param {number} timestamp + */ + banned_until: RequiredParams<"timestamp" | "timestamp">; /** * Y​o​u​ ​a​r​e​ ​n​o​t​ ​a​l​l​o​w​e​d​ ​t​o​ ​c​r​e​a​t​e​ ​t​h​a​t​ ​t​i​c​k​e​t​ ​t​y​p​e​. */ @@ -1004,6 +1072,60 @@ export type TranslationFunctions = { }; }; commands: { + ticketban: { + /** + * Ban a user from opening tickets + */ + description: () => LocalizedString; + options: { + user: { + /** + * The user whose ticket ban to manage + */ + description: () => LocalizedString; + }; + duration: { + /** + * Duration (e.g. 30m, 2h, 7d, 1d12h); omit or use lifetime for a lifetime ban + */ + description: () => LocalizedString; + }; + }; + /** + * You need the Ban Members permission to manage ticket bans in this server. + */ + not_allowed: () => LocalizedString; + /** + * Choose a user whose ticket ban to manage. + */ + choose_user: () => LocalizedString; + /** + * Enter a positive duration using s, m, h, d, or w (e.g. 30m, 7d, 1d12h), or lifetime. + */ + invalid_duration: () => LocalizedString; + /** + * <@{userId}> is banned from opening tickets for life. + */ + success_lifetime: (arg: { userId: string }) => LocalizedString; + /** + * <@{userId}> is banned from opening tickets until (). + */ + success_until: (arg: { timestamp: number; userId: string }) => LocalizedString; + }; + ticketunban: { + /** + * Lift a user's ticket ban + */ + description: () => LocalizedString; + /** + * <@{userId}> can open tickets again. + */ + success: (arg: { userId: string }) => LocalizedString; + /** + * <@{userId}> has no active ticket ban. + */ + not_banned: (arg: { userId: string }) => LocalizedString; + }; add: { /** * Add someone to the current ticket @@ -1253,6 +1375,14 @@ export type TranslationFunctions = { select_placeholder: () => LocalizedString; }; open: { + /** + * You are banned from opening tickets for life. + */ + banned_lifetime: () => LocalizedString; + /** + * You are banned from opening tickets until (). + */ + banned_until: (arg: { timestamp: number }) => LocalizedString; /** * You are not allowed to create that ticket type. */ diff --git a/package.json b/package.json index 24f1891b..783e72d3 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "node:start": "node dist/src/index.js", "migrate:v3": "bun src/migrate-v3-db.ts", "migrate:v3:config": "bun src/migrate-v3-config.ts", - "migrate:v3:test": "bun scripts/verify-migrate-v3.mjs" + "migrate:v3:test": "bun scripts/verify-migrate-v3.mjs", + "ticketban:test": "bun scripts/verify-ticketban.mjs" }, "dependencies": { "@discordjs/core": "2.6.0", diff --git a/scripts/verify-migrate-v3.mjs b/scripts/verify-migrate-v3.mjs index b1618f0e..f9536a52 100644 --- a/scripts/verify-migrate-v3.mjs +++ b/scripts/verify-migrate-v3.mjs @@ -35,6 +35,8 @@ async function verifyBasicMigration() { await runMigration(["--source", sourceUrl, "--target", targetUrl, ...(firstPanelKey ? ["--panel-key", firstPanelKey] : [])]); await withClient(targetUrl, async (target) => { + const bans = await target.execute("SELECT * FROM ticket_bans"); + assert(bans.rows.length === 0, "basic migration should create the ticket bans table without banning anyone."); const tickets = await target.execute("SELECT * FROM tickets ORDER BY id ASC"); assert(tickets.rows.length === 2, "basic migration should copy two tickets."); assert(Number(tickets.rows[0].id) === 10, "basic migration should preserve ticket IDs."); diff --git a/scripts/verify-ticketban.mjs b/scripts/verify-ticketban.mjs new file mode 100644 index 00000000..15c0b82d --- /dev/null +++ b/scripts/verify-ticketban.mjs @@ -0,0 +1,224 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { ComponentType, PermissionFlagsBits } from "@discordjs/core"; +import { createClient } from "@libsql/client/sqlite3"; +import { drizzle } from "drizzle-orm/libsql"; +import exampleConfig from "../config/config.example.ts"; +import { createBotI18n } from "../src/core/i18n.ts"; +import ticketban from "../src/features/commands/ticketban/command.ts"; +import ticketunban from "../src/features/commands/ticketunban/command.ts"; +import { + canManageTicketBans, + getActiveTicketBan, + parseTicketBanDuration, + removeTicketBan, + setTicketBan +} from "../src/features/tickets/bans.ts"; +import { continueTicketOpen, handleOpenFormSubmit } from "../src/features/tickets/ticket-workflow.ts"; + +const now = 1_800_000_000_000; +for (const input of [null, "lifetime", " PERMANENT "]) { + assert.deepEqual(parseTicketBanDuration(input, now), { ok: true, expiresAt: null }); +} +for (const [input, milliseconds] of [ + ["30s", 30_000], + ["30m", 1_800_000], + ["2h", 7_200_000], + ["7d", 604_800_000], + ["1w", 604_800_000], + ["1d12h", 129_600_000], + [" 1D 12H ", 129_600_000] +]) { + assert.deepEqual(parseTicketBanDuration(input, now), { ok: true, expiresAt: now + milliseconds }); +} +for (const input of ["", " ", "0s", "-1h", "1.5h", "7", "forever", "1month", "1h junk", "1 0h", "999999999999999999999w"]) { + assert.deepEqual(parseTicketBanDuration(input, now), { ok: false }, input); +} + +const root = fileURLToPath(new URL("..", import.meta.url)); +await mkdir(path.join(root, ".data"), { recursive: true }); +const fixture = process.argv[2] ?? (await mkdtemp(path.join(root, ".data", "ticketban-test-"))); +assert(fixture.startsWith(`${path.join(root, ".data")}${path.sep}`)); +if (!process.argv[2]) { + try { + // Windows retains native SQLite handles until process exit; clean up from the parent. + const checked = spawnSync(process.execPath, [fileURLToPath(import.meta.url), fixture], { + cwd: root, + encoding: "utf8", + timeout: 60_000 + }); + assert.equal(checked.status, 0, checked.stderr || checked.stdout); + console.log(checked.stdout.trim()); + } finally { + await rm(fixture, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } + process.exit(0); +} +const databaseUrl = `file:${path.join(fixture, "fixture.db")}`; +let client; + +try { + // Run the real schema push only against this disposable fixture, never config/.env's database. + const pushed = spawnSync(process.execPath, ["node_modules/drizzle-kit/bin.cjs", "push", "--force"], { + cwd: root, + env: { ...process.env, DB_FILE_NAME: databaseUrl }, + encoding: "utf8" + }); + assert.equal(pushed.status, 0, pushed.stderr || pushed.stdout); + client = createClient({ url: databaseUrl }); + const calls = []; + const config = { + ...exampleConfig, + guildId: "guild-1", + ticketTypes: { + test: { + name: "Test", + categoryId: "category-1", + openForm: { title: "Test", questions: [{ key: "reason", label: "Reason" }] } + } + } + }; + const app = { + db: drizzle(client), + config, + applicationId: "app-1", + LL: createBotI18n("en").LL, + client: { + api: { + interactions: Object.fromEntries( + ["reply", "defer", "editReply", "followUp", "updateMessage", "createModal"].map((name) => [ + name, + async (...args) => { + calls.push({ name, body: args.at(-1) }); + } + ]) + ), + guilds: { + createChannel: async () => { + throw new Error("ticket creation reached"); + } + } + } + } + }; + const interaction = { + id: "interaction-1", + token: "test", + guild_id: config.guildId, + member: { + permissions: PermissionFlagsBits.BanMembers.toString(), + roles: [], + user: { id: "moderator-1", username: "Moderator" } + }, + data: { options: [{ name: "user", value: "user-1" }] } + }; + assert(canManageTicketBans(app, interaction)); + assert( + canManageTicketBans(app, { + ...interaction, + member: { ...interaction.member, permissions: PermissionFlagsBits.Administrator.toString() } + }) + ); + assert(!canManageTicketBans(app, { ...interaction, member: { ...interaction.member, permissions: "0" } })); + assert(!canManageTicketBans(app, { ...interaction, guild_id: "other-guild" })); + assert(!canManageTicketBans(app, { ...interaction, member: undefined, guild_id: undefined })); + assert.equal(ticketban.data(app.LL).default_member_permissions, PermissionFlagsBits.BanMembers.toString()); + assert.equal(ticketunban.data(app.LL).default_member_permissions, PermissionFlagsBits.BanMembers.toString()); + + await ticketban.execute({ app }, interaction); + assert.equal((await getActiveTicketBan(app, "user-1")).expiresAt, null); + assert.equal((await getActiveTicketBan(app, "user-1")).bannedBy, "moderator-1"); + assert.match(calls.at(-1).body.content, /for life/); + await ticketban.execute( + { app }, + { ...interaction, data: { options: [...interaction.data.options, { name: "duration", value: "2h" }] } } + ); + const timed = await getActiveTicketBan(app, "user-1"); + assert(timed.expiresAt > Date.now() + 7_190_000); + assert.match(calls.at(-1).body.content, /until call.name), + componentType === ComponentType.Button ? ["reply"] : ["updateMessage", "followUp"] + ); + assert.match(calls.at(-1).body.content, /for life/); + } + const formInteraction = { ...userInteraction, data: { components: [] } }; + calls.length = 0; + await handleOpenFormSubmit({ app, route: { state: ["test"] } }, formInteraction); + assert.deepEqual( + calls.map((call) => call.name), + ["defer", "editReply"] + ); + assert.match(calls.at(-1).body.content, /for life/); + + await removeTicketBan(app, "user-1"); + calls.length = 0; + await continueTicketOpen(app, userInteraction, { ticketTypeKey: "test" }); + assert.equal(calls.at(-1).name, "createModal", "unbanned users can open the form"); + await setTicketBan(app, "user-1", "moderator-1", Date.now() + 60_000); + await handleOpenFormSubmit({ app, route: { state: ["test"] } }, formInteraction); + assert.match(calls.at(-1).body.content, /until handleOpenFormSubmit({ app, route: { state: ["test"] } }, formInteraction), + /ticket creation reached/ + ); + assert.equal(await removeTicketBan(app, "user-1"), false); + + // A select consumes its initial response; bans imposed during that update need a follow-up. + delete config.ticketTypes.test.openForm; + app.client.api.interactions.updateMessage = async () => { + calls.push({ name: "updateMessage" }); + await setTicketBan(app, "user-1", "moderator-1", null); + }; + calls.length = 0; + await continueTicketOpen( + app, + { ...userInteraction, data: { component_type: ComponentType.StringSelect } }, + { ticketTypeKey: "test" } + ); + assert.deepEqual( + calls.map((call) => call.name), + ["updateMessage", "followUp"] + ); + assert.match(calls.at(-1).body.content, /for life/); + console.log("[ticketban:test] Duration, permissions, persistence, expiry, unban, panel and form checks passed."); +} finally { + client?.close(); +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 01d35523..232b17fc 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -13,7 +13,7 @@ project repository or to its website. This notice must not be removed, obscured, or replaced. */ -import { int, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { int, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; // NOTE: Columns that does not have notNull constraint ARE nullable. @@ -30,6 +30,20 @@ export const appMetaTable = sqliteTable("app_meta", { updatedAt: int().notNull() }); +export const ticketBansTable = sqliteTable( + "ticket_bans", + { + guildId: text().notNull(), + userId: text().notNull(), + bannedBy: text().notNull(), + /** Unix time in milliseconds. */ + createdAt: int().notNull(), + /** Null means lifetime; otherwise Unix time in milliseconds. */ + expiresAt: int() + }, + (table) => [primaryKey({ columns: [table.guildId, table.userId] })] +); + export const ticketsTable = sqliteTable("tickets", { id: int().primaryKey({ autoIncrement: true }), /** The ID of the channel where the ticket was created. */ diff --git a/src/features/commands/ticketban/command.ts b/src/features/commands/ticketban/command.ts new file mode 100644 index 00000000..57253b59 --- /dev/null +++ b/src/features/commands/ticketban/command.ts @@ -0,0 +1,70 @@ +/* +Ticket-Bot is licensed under the GNU Affero General Public License, +version 3 only ("AGPL-3.0-only"). See LICENSE.md for the full license text. + +Additional Term under GNU AGPL v3, Section 7(b): + +You are required to preserve and display, in a location clearly visible +to end users interacting with the bot (such as bot embeds, the bot's +"Bio" Discord profile, status, or equivalent), a notice that the +software is powered by Ticket-Bot, including a link to the original +project repository or to its website. + +This notice must not be removed, obscured, or replaced. +*/ + +import { MessageFlags, PermissionFlagsBits } from "@discordjs/core"; +import { ApplicationCommandOptionType } from "discord-api-types/v10"; +import { defineCommand } from "@/core/defineCommand"; +import { deferReply, editReply, reply } from "@/core/respond"; +import { getStringOption, getUserOption } from "@/features/commands/shared/options"; +import { canManageTicketBans, parseTicketBanDuration, setTicketBan } from "@/features/tickets/bans"; +import { getInteractionUser } from "@/features/tickets/utils"; + +export default defineCommand({ + data: (LL) => ({ + name: "ticketban", + description: LL.commands.ticketban.description(), + default_member_permissions: PermissionFlagsBits.BanMembers.toString(), + options: [ + { + name: "user", + description: LL.commands.ticketban.options.user.description(), + required: true, + type: ApplicationCommandOptionType.User + }, + { + name: "duration", + description: LL.commands.ticketban.options.duration.description(), + type: ApplicationCommandOptionType.String, + max_length: 100 + } + ] + }), + async execute({ app }, interaction) { + const LL = app.LL.commands.ticketban; + if (!canManageTicketBans(app, interaction)) { + await reply(app, interaction, { content: LL.not_allowed(), flags: MessageFlags.Ephemeral }); + return; + } + const user = getUserOption(interaction, "user"); + if (!user) { + await reply(app, interaction, { content: LL.choose_user(), flags: MessageFlags.Ephemeral }); + return; + } + const duration = parseTicketBanDuration(getStringOption(interaction, "duration")); + if (!duration.ok) { + await reply(app, interaction, { content: LL.invalid_duration(), flags: MessageFlags.Ephemeral }); + return; + } + await deferReply(app, interaction, { flags: MessageFlags.Ephemeral }); + await setTicketBan(app, user.userId, getInteractionUser(interaction).id, duration.expiresAt); + await editReply(app, interaction, { + content: + duration.expiresAt === null + ? LL.success_lifetime({ userId: user.userId }) + : LL.success_until({ userId: user.userId, timestamp: Math.ceil(duration.expiresAt / 1_000) }), + allowed_mentions: { parse: [] } + }); + } +}); diff --git a/src/features/commands/ticketunban/command.ts b/src/features/commands/ticketunban/command.ts new file mode 100644 index 00000000..12d339b0 --- /dev/null +++ b/src/features/commands/ticketunban/command.ts @@ -0,0 +1,56 @@ +/* +Ticket-Bot is licensed under the GNU Affero General Public License, +version 3 only ("AGPL-3.0-only"). See LICENSE.md for the full license text. + +Additional Term under GNU AGPL v3, Section 7(b): + +You are required to preserve and display, in a location clearly visible +to end users interacting with the bot (such as bot embeds, the bot's +"Bio" Discord profile, status, or equivalent), a notice that the +software is powered by Ticket-Bot, including a link to the original +project repository or to its website. + +This notice must not be removed, obscured, or replaced. +*/ + +import { MessageFlags, PermissionFlagsBits } from "@discordjs/core"; +import { ApplicationCommandOptionType } from "discord-api-types/v10"; +import { defineCommand } from "@/core/defineCommand"; +import { deferReply, editReply, reply } from "@/core/respond"; +import { getUserOption } from "@/features/commands/shared/options"; +import { canManageTicketBans, removeTicketBan } from "@/features/tickets/bans"; + +export default defineCommand({ + data: (LL) => ({ + name: "ticketunban", + description: LL.commands.ticketunban.description(), + default_member_permissions: PermissionFlagsBits.BanMembers.toString(), + options: [ + { + name: "user", + description: LL.commands.ticketban.options.user.description(), + required: true, + type: ApplicationCommandOptionType.User + } + ] + }), + async execute({ app }, interaction) { + if (!canManageTicketBans(app, interaction)) { + await reply(app, interaction, { content: app.LL.commands.ticketban.not_allowed(), flags: MessageFlags.Ephemeral }); + return; + } + const user = getUserOption(interaction, "user"); + if (!user) { + await reply(app, interaction, { content: app.LL.commands.ticketban.choose_user(), flags: MessageFlags.Ephemeral }); + return; + } + await deferReply(app, interaction, { flags: MessageFlags.Ephemeral }); + const removed = await removeTicketBan(app, user.userId); + await editReply(app, interaction, { + content: removed + ? app.LL.commands.ticketunban.success({ userId: user.userId }) + : app.LL.commands.ticketunban.not_banned({ userId: user.userId }), + allowed_mentions: { parse: [] } + }); + } +}); diff --git a/src/features/tickets/bans.ts b/src/features/tickets/bans.ts new file mode 100644 index 00000000..4dac77b6 --- /dev/null +++ b/src/features/tickets/bans.ts @@ -0,0 +1,116 @@ +/* +Ticket-Bot is licensed under the GNU Affero General Public License, +version 3 only ("AGPL-3.0-only"). See LICENSE.md for the full license text. + +Additional Term under GNU AGPL v3, Section 7(b): + +You are required to preserve and display, in a location clearly visible +to end users interacting with the bot (such as bot embeds, the bot's +"Bio" Discord profile, status, or equivalent), a notice that the +software is powered by Ticket-Bot, including a link to the original +project repository or to its website. + +This notice must not be removed, obscured, or replaced. +*/ + +import type { APIChatInputApplicationCommandInteraction } from "@discordjs/core"; +import { PermissionFlagsBits } from "@discordjs/core"; +import { and, eq, gt, isNull, or } from "drizzle-orm"; +import type { BotApp } from "@/core/types"; +import { ticketBansTable } from "@/db/schema"; + +type BanApp = Pick; + +const DURATION_UNITS = new Map([ + ["s", 1_000], + ["m", 60_000], + ["h", 3_600_000], + ["d", 86_400_000], + ["w", 604_800_000] +]); + +/** Parse explicit units so a bare number cannot accidentally become a lifetime ban. */ +export function parseTicketBanDuration(input: string | null, now = Date.now()) { + if (input === null || /^(lifetime|permanent)$/i.test(input.trim())) { + return { ok: true, expiresAt: null } as const; + } + + const normalized = input.trim().toLowerCase(); + const parts = normalized.matchAll(/(\d+)\s*([smhdw])/g); + let consumed = ""; + let duration = 0; + + for (const part of parts) { + const multiplier = DURATION_UNITS.get(part[2]); + if (!multiplier) { + return { ok: false } as const; + } + consumed += part[0].replace(/\s/g, ""); + duration += Number(part[1]) * multiplier; + } + + const expiresAt = now + duration; + if ( + consumed !== normalized.replace(/\s/g, "") || + duration <= 0 || + !Number.isSafeInteger(expiresAt) || + expiresAt > 8_640_000_000_000_000 + ) { + return { ok: false } as const; + } + + return { ok: true, expiresAt } as const; +} + +/** Enforce moderation permissions even if Discord command permissions are overridden. */ +export function canManageTicketBans(app: Pick, interaction: APIChatInputApplicationCommandInteraction) { + if (interaction.guild_id !== app.config.guildId || !interaction.member) { + return false; + } + const permissions = BigInt(interaction.member.permissions); + return (permissions & (PermissionFlagsBits.BanMembers | PermissionFlagsBits.Administrator)) !== 0n; +} + +/** Expiry is checked on reads, so bans expire even while the bot is offline. */ +export async function getActiveTicketBan(app: BanApp, userId: string, now = Date.now()) { + const rows = await app.db + .select() + .from(ticketBansTable) + .where( + and( + eq(ticketBansTable.guildId, app.config.guildId), + eq(ticketBansTable.userId, userId), + or(isNull(ticketBansTable.expiresAt), gt(ticketBansTable.expiresAt, now)) + ) + ) + .limit(1); + return rows[0] ?? null; +} + +/** Reissuing the command replaces the existing ban and its expiry atomically. */ +export async function setTicketBan(app: BanApp, userId: string, bannedBy: string, expiresAt: number | null) { + const values = { guildId: app.config.guildId, userId, bannedBy, createdAt: Date.now(), expiresAt }; + await app.db + .insert(ticketBansTable) + .values(values) + .onConflictDoUpdate({ target: [ticketBansTable.guildId, ticketBansTable.userId], set: values }); +} + +export async function removeTicketBan(app: BanApp, userId: string) { + const rows = await app.db + .delete(ticketBansTable) + .where(and(eq(ticketBansTable.guildId, app.config.guildId), eq(ticketBansTable.userId, userId))) + .returning(); + const ban = rows[0]; + return Boolean(ban && (ban.expiresAt === null || ban.expiresAt > Date.now())); +} + +export async function getTicketBanMessage(app: Pick, userId: string) { + const ban = await getActiveTicketBan(app, userId); + if (!ban) { + return null; + } + return ban.expiresAt === null + ? app.LL.tickets.open.banned_lifetime() + : app.LL.tickets.open.banned_until({ timestamp: Math.ceil(ban.expiresAt / 1_000) }); +} diff --git a/src/features/tickets/ticket-workflow.ts b/src/features/tickets/ticket-workflow.ts index dc5f6bc3..b5be6ba2 100644 --- a/src/features/tickets/ticket-workflow.ts +++ b/src/features/tickets/ticket-workflow.ts @@ -41,6 +41,7 @@ import { deferReply, editReply, followUp, reply, showModal, updateMessage } from import type { BotApp, ComponentExecutionContext } from "@/core/types"; import { type TicketRecord, ticketsTable } from "@/db/schema"; import { sendTicketLog } from "@/features/logs/service"; +import { getTicketBanMessage } from "@/features/tickets/bans"; import { getPanel, getPanelTicketTypeKeys, @@ -103,6 +104,14 @@ export async function continueTicketOpen(app: BotApp, interaction: APIMessageCom const openForm = ticketType.openForm; const hasOpenForm = Boolean(openForm?.questions.length); const isStringSelect = interaction.data.component_type === ComponentType.StringSelect; + const banMessage = await getTicketBanMessage(app, getInteractionUser(interaction).id); + if (banMessage) { + if (isStringSelect) { + await updateMessage(app, interaction, {}); + } + await respondToTicketOpen(app, interaction, banMessage, isStringSelect ? "follow-up" : "initial"); + return; + } if (!userCanAccessTicketType(app, ticketType, roleIds)) { if (isStringSelect) { @@ -281,6 +290,17 @@ async function createTicket( } const user = getInteractionUser(interaction); + // Recheck at creation because a moderator can ban a user while their form is open. + const banMessage = await getTicketBanMessage(app, user.id); + if (banMessage) { + const body = { content: banMessage, flags: MessageFlags.Ephemeral }; + if (responseMode === "follow-up") { + await followUp(app, interaction, body); + } else { + await editReply(app, interaction, body); + } + return; + } // Reserve before Discord work so every rendered number matches the eventual database primary key. const ticketId = await reserveTicketId(app); const ticketNumber = ticketId.toString(); diff --git a/src/migrate-v3-db.ts b/src/migrate-v3-db.ts index e8b7320e..322886ac 100644 --- a/src/migrate-v3-db.ts +++ b/src/migrate-v3-db.ts @@ -106,6 +106,16 @@ CREATE TABLE IF NOT EXISTS app_meta ( updatedAt INTEGER NOT NULL )`; +const CREATE_TICKET_BANS_TABLE = ` +CREATE TABLE IF NOT EXISTS ticket_bans ( + guildId TEXT NOT NULL, + userId TEXT NOT NULL, + bannedBy TEXT NOT NULL, + createdAt INTEGER NOT NULL, + expiresAt INTEGER, + PRIMARY KEY (guildId, userId) +)`; + loadEnv({ path: "./config/.env", quiet: true }); runFromCli().catch((error) => { @@ -445,7 +455,10 @@ async function readOpenTicketMessageId(source: Client) { } async function ensureV4Schema(target: Client) { - await target.batch([CREATE_TICKETS_TABLE, CREATE_PANEL_MESSAGES_TABLE, CREATE_APP_META_TABLE], "write"); + await target.batch( + [CREATE_TICKETS_TABLE, CREATE_PANEL_MESSAGES_TABLE, CREATE_APP_META_TABLE, CREATE_TICKET_BANS_TABLE], + "write" + ); } async function assertTargetCanBeUsed(target: Client, overwrite: boolean) {