diff --git a/README.md b/README.md index 03cc4004..011620dd 100644 --- a/README.md +++ b/README.md @@ -92,10 +92,6 @@ Retrieves the profile picture of the given user. **[Admin Only by default]** Manually triggers the room finder data scraper to update the database with current semester schedule information. This is useful for forcing an immediate update without waiting for the automatic Sunday schedule. Takes 10-15 minutes to complete and runs in the background. Shows real-time progress if a scrape is already running. -### /sendtag - -Not complete. For now, this command simply auto-completes the tag the user types, but it does not send the tag. - ### /setreactboard Creates a new reactboard or updates an existing one. A reactboard is a channel where the bot will repost messages that recieve a specified number of a specified reaction. The primary use is for a starboard where messages that receive the right number of stars will be added, along with how many stars they received. @@ -108,6 +104,18 @@ Temporarily prevents a user from using bot commands for one hour. Only administr Tracks a statistic for the issuer. Use the `track` subcommand to begin tracking, `update` to add or subtract to it, `list` to show all the stats being tracked for the issuer, `leaderboard` to show the users with the highest scores for a stat, and `untrack` to stop tracking a stat for you. +### /tag ( add / send / preview / list / remove ) + +Manage and use tagged images and links within your server. Tags are server-specific and tracked with usage statistics. + +- **add** - Create a new tag with a name and content (URL or text). Maximum 100 characters for name, 2000 for content. +- **send** - Post a tag publicly in the channel. Automatically displays images as embeds. +- **preview** - View tag details privately (ephemeral), including creator, use count, and creation date. +- **list** - List all available tags in the server with their creators and use counts. +- **remove** - Delete a tag you created. Admins can delete any tag. + +Tag names support autocomplete for easy discovery. + ### /talk Uses the [dectalk](https://github.com/JstnMcBrd/dectalk-tts) text-to-speech engine to speak the message you give it, either sending a .wav file in a text channel or talking out loud in a voice channel. Comes with 9 different voices. diff --git a/package.json b/package.json index aa277854..79985108 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "csbot", - "version": "0.16.0", + "version": "0.17.0", "private": true, "description": "The One beneath the Supreme Overlord's rule. A bot to help manage the BYU CS Discord, successor to Ze Kaiser (https://github.com/arkenstorm/ze-kaiser)", "keywords": [ diff --git a/prisma/migrations/20251023164854_add_user_model/migration.sql b/prisma/migrations/20251023164854_add_user_model/migration.sql index 414f550a..b707a6e3 100644 --- a/prisma/migrations/20251023164854_add_user_model/migration.sql +++ b/prisma/migrations/20251023164854_add_user_model/migration.sql @@ -67,7 +67,9 @@ CREATE TABLE "Tag" ( "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "guildId" TEXT NOT NULL, "name" TEXT NOT NULL, - "content" TEXT NOT NULL, + "imageData" BLOB NOT NULL, + "fileName" TEXT NOT NULL, + "contentType" TEXT NOT NULL, "createdBy" TEXT NOT NULL, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "useCount" INTEGER NOT NULL DEFAULT 0 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e6369e93..05f83d20 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -78,13 +78,15 @@ model User { } model Tag { - id Int @id @default(autoincrement()) - guildId String // Discord guild ID - tags are per-guild - name String // Tag name (unique per guild) - content String // URL or text content - createdBy String // Discord user ID of creator - createdAt DateTime @default(now()) - useCount Int @default(0) // Track how many times tag has been used + id Int @id @default(autoincrement()) + guildId String // Discord guild ID - tags are per-guild + name String // Tag name (unique per guild) + imageData Bytes // Binary image data stored locally + fileName String // Original filename (e.g. "funny.png") + contentType String // MIME type (e.g. "image/png") + createdBy String // Discord user ID of creator + createdAt DateTime @default(now()) + useCount Int @default(0) // Track how many times tag has been used @@unique([guildId, name], name: "guild_tag") @@index([guildId]) diff --git a/src/@types/Command.d.ts b/src/@types/Command.d.ts index 48c22777..ba9b1cb3 100644 --- a/src/@types/Command.d.ts +++ b/src/@types/Command.d.ts @@ -30,7 +30,9 @@ declare global { */ autocomplete?: ( interaction: Omit - ) => Array; + ) => + | Array + | Promise>; } interface GlobalCommand extends BaseCommand { diff --git a/src/commands/index.ts b/src/commands/index.ts index 92f51adf..f26e09c1 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -35,11 +35,11 @@ import { help } from './help.js'; import { isCasDown } from './isCasDown.js'; import { profile } from './profile.js'; import { scrapeRooms } from './scrapeRooms.js'; -import { sendtag } from './sendtag.js'; import { setReactboard } from './setReactboard.js'; import { smite } from './smite.js'; import { unsmite } from './unsmite.js'; import { stats } from './stats.js'; +import { tag } from './tag.js'; import { talk } from './talk.js'; import { toTheGallows } from './toTheGallows.js'; import { xkcd } from './xkcd.js'; @@ -54,10 +54,10 @@ _add(help); _add(isCasDown); _add(profile); _add(scrapeRooms); -_add(sendtag); _add(setReactboard); _add(smite); _add(stats); +_add(tag); _add(unsmite); _add(talk); _add(toTheGallows); diff --git a/src/commands/sendtag.test.ts b/src/commands/sendtag.test.ts deleted file mode 100644 index b2385c32..00000000 --- a/src/commands/sendtag.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; - -import type { AutocompleteInteraction } from 'discord.js'; - -import { sendtag } from './sendtag.js'; - -describe('sendtag', () => { - const mockReply = vi.fn(); - const mockGetString = vi.fn(); - - let context: GuildedCommandContext; - - beforeEach(() => { - context = { - reply: mockReply, - options: { - getString: mockGetString, - getFocused: () => '', - }, - interaction: { - options: { - getString: mockGetString, - getFocused: () => '', - }, - }, - } as unknown as GuildedCommandContext; - - mockGetString.mockReturnValue(''); - }); - - test('presents an response with the given option string', async () => { - const value = 'lorem ipsum'; - mockGetString.mockReturnValue(value); - await sendtag.execute(context); - expect(mockReply).toHaveBeenCalledOnce(); - expect(mockReply).toHaveBeenCalledWith(`You requested the '${value}' tag!`); - }); - - test('returns an array for autocomplete', () => { - expect( - sendtag.autocomplete?.(context.interaction as unknown as AutocompleteInteraction) - ).toBeDefined(); - }); -}); diff --git a/src/commands/sendtag.ts b/src/commands/sendtag.ts deleted file mode 100644 index cadb7a45..00000000 --- a/src/commands/sendtag.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; - -const NameOption = 'name'; - -const info = new SlashCommandBuilder() - .setName('sendtag') - .setDescription('Posts a tagged reaction image (for testing purposes)') - .addStringOption(option => - option - .setName(NameOption) - .setDescription('The name of the tag') - .setRequired(true) - .setAutocomplete(true) - ); - -export const sendtag: GuildedCommand = { - info, - requiresGuild: true, - autocomplete(interaction) { - // Get the user-provided intermediate value - const incompleteValue = interaction.options.getFocused(); - - // TODO: Available tags for the guild. On startup, cache this list somewhere reasonable so we don't have to fetch from the database every time. We only have 3 seconds to respond, so we can't afford to fetch the list each time we need it. - const tags = [ - 'texans', - 'inthisphoto', - 'allstar', - 'detracted', - 'balancerestored', - 'tumblememe', - 'disappointed', - 'cleanse', - 'unsee', - 'banhammer', - 'thisisdating', - 'dosomething', - 'useless', - 'memesismemes', - 'intense', - 'politics', - 'justgetmarried', - 'what', - 'due', - 'hacc', - 'tehc', - 'futureme', - 'hco', - 'anarchy', - 'dannflorwasright', - 'thisiscs', - 'gostudy', - ]; - - // Respond with the available options - return tags - .filter(choice => choice.startsWith(incompleteValue)) // TODO: Use something like Fuse.js for fast fuzzy search - .map(choice => ({ name: choice, value: choice })); - }, - async execute({ reply, options }) { - const value = options.getString(NameOption, true); - // Note that autocomplete does not force the user to select one of the valid values. We should check here that the given value is a known one. - await reply(`You requested the '${value}' tag!`); - }, -}; diff --git a/src/commands/tag.test.ts b/src/commands/tag.test.ts new file mode 100644 index 00000000..97c14b8d --- /dev/null +++ b/src/commands/tag.test.ts @@ -0,0 +1,377 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AutocompleteInteraction } from 'discord.js'; +import { tag } from './tag.js'; +import * as tagUtils from '../helpers/tagUtils.js'; + +vi.mock('../helpers/tagUtils.js'); + +const fakeImageData = Buffer.from('fake-image-data'); + +describe('tag command', () => { + const mockReply = vi.fn(); + const mockGetString = vi.fn(); + const mockGetSubcommand = vi.fn(); + const mockGetAttachment = vi.fn(); + + let context: GuildedCommandContext; + + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(tagUtils.downloadAttachment).mockResolvedValue(fakeImageData); + vi.mocked(tagUtils.validateImageAttachment).mockImplementation(() => { + // no-op, valid by default + }); + + context = { + reply: mockReply, + options: { + getString: mockGetString, + getSubcommand: mockGetSubcommand, + getAttachment: mockGetAttachment, + getFocused: () => '', + }, + guild: { + id: 'guild123', + }, + user: { + id: 'user123', + username: 'TestUser', + }, + member: { + permissions: { + has: () => false, // Default to non-admin + }, + }, + interaction: { + guildId: 'guild123', + options: { + getFocused: () => '', + }, + }, + } as unknown as GuildedCommandContext; + }); + + describe('add subcommand', () => { + beforeEach(() => { + mockGetSubcommand.mockReturnValue('add'); + }); + + it('should create a tag with an image attachment', async () => { + mockGetString.mockImplementation((name: string) => { + if (name === 'name') return 'testtag'; + return null; + }); + + mockGetAttachment.mockReturnValue({ + name: 'photo.png', + contentType: 'image/png', + size: 1024, + url: 'https://cdn.discord.com/attachments/photo.png', + }); + + vi.mocked(tagUtils.createTag).mockResolvedValue({ + id: 1, + guildId: 'guild123', + name: 'testtag', + imageData: fakeImageData, + fileName: 'photo.png', + contentType: 'image/png', + createdBy: 'user123', + createdAt: new Date(), + useCount: 0, + }); + + await tag.execute(context); + + expect(tagUtils.validateImageAttachment).toHaveBeenCalledWith('image/png', 1024); + expect(tagUtils.downloadAttachment).toHaveBeenCalledWith( + 'https://cdn.discord.com/attachments/photo.png' + ); + expect(tagUtils.createTag).toHaveBeenCalledWith( + 'guild123', + 'testtag', + fakeImageData, + 'photo.png', + 'image/png', + 'user123' + ); + expect(mockReply).toHaveBeenCalledWith( + expect.objectContaining({ + ephemeral: true, + embeds: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + title: 'Tag Created', + }), + }), + ]), + files: expect.any(Array), + }) + ); + }); + + it('should handle duplicate tag name error', async () => { + mockGetString.mockImplementation((name: string) => { + if (name === 'name') return 'existing'; + return null; + }); + + mockGetAttachment.mockReturnValue({ + name: 'photo.png', + contentType: 'image/png', + size: 1024, + url: 'https://cdn.discord.com/attachments/photo.png', + }); + + vi.mocked(tagUtils.createTag).mockRejectedValue( + new Error("A tag named 'existing' already exists in this server.") + ); + + await expect(tag.execute(context)).rejects.toThrow(); + }); + }); + + describe('send subcommand', () => { + beforeEach(() => { + mockGetSubcommand.mockReturnValue('send'); + }); + + it('should send image as file attachment', async () => { + mockGetString.mockReturnValue('testtag'); + + vi.mocked(tagUtils.getTag).mockResolvedValue({ + id: 1, + guildId: 'guild123', + name: 'testtag', + imageData: fakeImageData, + fileName: 'photo.png', + contentType: 'image/png', + createdBy: 'user123', + createdAt: new Date(), + useCount: 5, + }); + + vi.mocked(tagUtils.incrementTagUseCount).mockResolvedValue(); + + await tag.execute(context); + + expect(tagUtils.getTag).toHaveBeenCalledWith('guild123', 'testtag'); + expect(tagUtils.incrementTagUseCount).toHaveBeenCalledWith('guild123', 'testtag'); + expect(mockReply).toHaveBeenCalledWith( + expect.objectContaining({ + files: expect.any(Array), + }) + ); + }); + + it('should throw error if tag not found', async () => { + mockGetString.mockReturnValue('nonexistent'); + vi.mocked(tagUtils.getTag).mockResolvedValue(null); + + await expect(tag.execute(context)).rejects.toThrow(); + }); + }); + + describe('preview subcommand', () => { + beforeEach(() => { + mockGetSubcommand.mockReturnValue('preview'); + }); + + it('should show image preview with metadata', async () => { + mockGetString.mockReturnValue('testtag'); + + const mockDate = new Date('2023-01-01'); + vi.mocked(tagUtils.getTag).mockResolvedValue({ + id: 1, + guildId: 'guild123', + name: 'testtag', + imageData: fakeImageData, + fileName: 'photo.png', + contentType: 'image/png', + createdBy: 'user123', + createdAt: mockDate, + useCount: 10, + }); + + await tag.execute(context); + + expect(tagUtils.getTag).toHaveBeenCalledWith('guild123', 'testtag'); + expect(mockReply).toHaveBeenCalledWith( + expect.objectContaining({ + ephemeral: true, + embeds: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + title: 'Preview: testtag', + image: expect.objectContaining({ + url: 'attachment://photo.png', + }), + fields: expect.arrayContaining([ + expect.objectContaining({ + name: 'File', + value: 'photo.png', + }), + ]), + }), + }), + ]), + files: expect.any(Array), + }) + ); + }); + + it('should throw error if tag not found', async () => { + mockGetString.mockReturnValue('nonexistent'); + vi.mocked(tagUtils.getTag).mockResolvedValue(null); + + await expect(tag.execute(context)).rejects.toThrow(); + }); + }); + + describe('list subcommand', () => { + beforeEach(() => { + mockGetSubcommand.mockReturnValue('list'); + }); + + it('should list all tags', async () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + vi.mocked(tagUtils.getAllTags).mockResolvedValue([ + { + id: 1, + guildId: 'guild123', + name: 'tag1', + fileName: 'img1.png', + contentType: 'image/png', + createdBy: 'user1', + createdAt: new Date(), + useCount: 5, + }, + { + id: 2, + guildId: 'guild123', + name: 'tag2', + fileName: 'img2.gif', + contentType: 'image/gif', + createdBy: 'user2', + createdAt: new Date(), + useCount: 10, + }, + ]); + + await tag.execute(context); + + expect(tagUtils.getAllTags).toHaveBeenCalledWith('guild123'); + expect(mockReply).toHaveBeenCalledWith( + expect.objectContaining({ + ephemeral: true, + embeds: expect.any(Array), + }) + ); + }); + + it('should show message when no tags exist', async () => { + vi.mocked(tagUtils.getAllTags).mockResolvedValue([]); + + await tag.execute(context); + + expect(mockReply).toHaveBeenCalledWith( + expect.objectContaining({ + ephemeral: true, + embeds: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + description: expect.stringContaining('No tags available'), + }), + }), + ]), + }) + ); + }); + }); + + describe('remove subcommand', () => { + beforeEach(() => { + mockGetSubcommand.mockReturnValue('remove'); + }); + + it('should delete tag successfully', async () => { + mockGetString.mockReturnValue('testtag'); + vi.mocked(tagUtils.deleteTag).mockResolvedValue(true); + + await tag.execute(context); + + expect(tagUtils.deleteTag).toHaveBeenCalledWith('guild123', 'testtag', 'user123', false); + expect(mockReply).toHaveBeenCalledWith( + expect.objectContaining({ + ephemeral: true, + embeds: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + title: 'Tag Removed', + }), + }), + ]), + }) + ); + }); + + it('should throw error if tag not found', async () => { + mockGetString.mockReturnValue('nonexistent'); + vi.mocked(tagUtils.deleteTag).mockResolvedValue(false); + + await expect(tag.execute(context)).rejects.toThrow(); + }); + + it('should handle unauthorized deletion', async () => { + mockGetString.mockReturnValue('testtag'); + vi.mocked(tagUtils.deleteTag).mockRejectedValue( + new Error('You can only delete tags you created.') + ); + + await expect(tag.execute(context)).rejects.toThrow(); + }); + }); + + describe('autocomplete', () => { + it('should return matching tag names', async () => { + const mockInteraction = { + guildId: 'guild123', + options: { + getFocused: () => 'test', + }, + } as unknown as AutocompleteInteraction; + + vi.mocked(tagUtils.searchTags).mockResolvedValue(['test1', 'test2', 'testing']); + + const autocomplete = tag.autocomplete as ( + interaction: AutocompleteInteraction + ) => Promise>; + const result = await autocomplete(mockInteraction); + + expect(tagUtils.searchTags).toHaveBeenCalledWith('guild123', 'test'); + expect(result).toEqual([ + { name: 'test1', value: 'test1' }, + { name: 'test2', value: 'test2' }, + { name: 'testing', value: 'testing' }, + ]); + }); + + it('should return empty array if no guildId', async () => { + const mockInteraction = { + guildId: null, + options: { + getFocused: () => 'test', + }, + } as unknown as AutocompleteInteraction; + + const autocomplete = tag.autocomplete as ( + interaction: AutocompleteInteraction + ) => Promise>; + const result = await autocomplete(mockInteraction); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/src/commands/tag.ts b/src/commands/tag.ts new file mode 100644 index 00000000..1cfaf2ec --- /dev/null +++ b/src/commands/tag.ts @@ -0,0 +1,287 @@ +import { AttachmentBuilder, EmbedBuilder, SlashCommandBuilder } from 'discord.js'; +import { + createTag, + getTag, + getAllTags, + incrementTagUseCount, + deleteTag, + searchTags, + validateImageAttachment, + downloadAttachment, +} from '../helpers/tagUtils.js'; +import { UserMessageError } from '../helpers/UserMessageError.js'; +import { isAdmin } from '../helpers/smiteUtils.js'; + +const NameOption = 'name'; +const ImageOption = 'image'; + +const AddSubcommand = 'add'; +const SendSubcommand = 'send'; +const PreviewSubcommand = 'preview'; +const ListSubcommand = 'list'; +const RemoveSubcommand = 'remove'; + +const builder = new SlashCommandBuilder() + .setName('tag') + .setDescription('Manage and use tagged images') + .addSubcommand(subcommand => + subcommand + .setName(AddSubcommand) + .setDescription('Create a new tag from an image') + .addStringOption(option => + option + .setName(NameOption) + .setDescription('The name of the tag') + .setRequired(true) + .setMaxLength(100) + ) + .addAttachmentOption(option => + option + .setName(ImageOption) + .setDescription('The image to store (PNG, JPEG, GIF, WebP, max 10 MB)') + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName(SendSubcommand) + .setDescription('Post a tag publicly') + .addStringOption(option => + option + .setName(NameOption) + .setDescription('The name of the tag to send') + .setRequired(true) + .setAutocomplete(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName(PreviewSubcommand) + .setDescription('Preview a tag privately (ephemeral)') + .addStringOption(option => + option + .setName(NameOption) + .setDescription('The name of the tag to preview') + .setRequired(true) + .setAutocomplete(true) + ) + ) + .addSubcommand(subcommand => + subcommand.setName(ListSubcommand).setDescription('List all available tags in this server') + ) + .addSubcommand(subcommand => + subcommand + .setName(RemoveSubcommand) + .setDescription('Remove a tag you created') + .addStringOption(option => + option + .setName(NameOption) + .setDescription('The name of the tag to remove') + .setRequired(true) + .setAutocomplete(true) + ) + ); + +export const tag: GuildedCommand = { + info: builder, + requiresGuild: true, + async autocomplete(interaction) { + const guildId = interaction.guildId; + if (!guildId) return []; + + const focusedValue = interaction.options.getFocused(); + const tags = await searchTags(guildId, focusedValue); + + return tags.map(name => ({ name, value: name })); + }, + async execute({ reply, options, guild, user, member }): Promise { + const subcommand = options.getSubcommand(); + + switch (subcommand) { + case AddSubcommand: { + const name = options.getString(NameOption, true); + const attachment = options.getAttachment(ImageOption, true); + + try { + validateImageAttachment(attachment.contentType, attachment.size); + const imageData = await downloadAttachment(attachment.url); + await createTag( + guild.id, + name, + imageData, + attachment.name, + attachment.contentType!, + user.id + ); + + const file = new AttachmentBuilder(imageData, { name: attachment.name }); + const embed = new EmbedBuilder() + .setTitle('Tag Created') + .setDescription(`Tag \`${name}\` has been created!`) + .setImage(`attachment://${attachment.name}`) + .setColor(0x00_ff_00) // Green + .setFooter({ text: `Created by ${user.username}` }); + + await reply({ + embeds: [embed], + files: [file], + ephemeral: true, + }); + } catch (error) { + if (error instanceof Error) { + throw new UserMessageError(error.message); + } + throw error; + } + break; + } + + case SendSubcommand: { + const name = options.getString(NameOption, true); + const tagData = await getTag(guild.id, name); + + if (!tagData) { + throw new UserMessageError( + `Tag \`${name}\` not found. Use \`/tag list\` to see available tags.` + ); + } + + // Increment use count + await incrementTagUseCount(guild.id, name); + + const file = new AttachmentBuilder(Buffer.from(tagData.imageData), { + name: tagData.fileName, + }); + + await reply({ files: [file] }); + break; + } + + case PreviewSubcommand: { + const name = options.getString(NameOption, true); + const tagData = await getTag(guild.id, name); + + if (!tagData) { + throw new UserMessageError( + `Tag \`${name}\` not found. Use \`/tag list\` to see available tags.` + ); + } + + const file = new AttachmentBuilder(Buffer.from(tagData.imageData), { + name: tagData.fileName, + }); + + const embed = new EmbedBuilder() + .setTitle(`Preview: ${name}`) + .setImage(`attachment://${tagData.fileName}`) + .addFields( + { + name: 'Created By', + value: `<@${tagData.createdBy}>`, + inline: true, + }, + { + name: 'Uses', + value: tagData.useCount.toString(), + inline: true, + }, + { + name: 'Created', + value: tagData.createdAt.toLocaleDateString(), + inline: true, + }, + { + name: 'File', + value: tagData.fileName, + inline: true, + } + ) + .setColor(0x58_65_f2); // Blurple + + await reply({ + embeds: [embed], + files: [file], + ephemeral: true, + }); + break; + } + + case ListSubcommand: { + const tags = await getAllTags(guild.id); + + if (tags.length === 0) { + await reply({ + embeds: [ + new EmbedBuilder() + .setTitle('Tags') + .setDescription('No tags available in this server.\n\nCreate one with `/tag add`!') + .setColor(0xff_a5_00), // Orange + ], + ephemeral: true, + }); + return; + } + + // Split into multiple embeds if there are too many tags + const maxFieldsPerEmbed = 25; + const embeds: Array = []; + + for (let i = 0; i < tags.length; i += maxFieldsPerEmbed) { + const chunk = tags.slice(i, i + maxFieldsPerEmbed); + const embed = new EmbedBuilder() + .setTitle(i === 0 ? `Tags (${tags.length} total)` : 'Tags (continued)') + .setColor(0x58_65_f2); // Blurple + + for (const tagData of chunk) { + embed.addFields({ + name: tagData.name, + value: `Uses: ${tagData.useCount} | By <@${tagData.createdBy}>`, + inline: false, + }); + } + + embeds.push(embed); + } + + await reply({ embeds, ephemeral: true }); + break; + } + + case RemoveSubcommand: { + const name = options.getString(NameOption, true); + + try { + // Check if user is an admin + const userIsAdmin = isAdmin(member); + const deleted = await deleteTag(guild.id, name, user.id, userIsAdmin); + + if (!deleted) { + throw new UserMessageError( + `Tag \`${name}\` not found. Use \`/tag list\` to see available tags.` + ); + } + + await reply({ + embeds: [ + new EmbedBuilder() + .setTitle('Tag Removed') + .setDescription(`Tag \`${name}\` has been deleted.`) + .setColor(0xff_00_00), // Red + ], + ephemeral: true, + }); + } catch (error) { + if (error instanceof Error) { + throw new UserMessageError(error.message); + } + throw error; + } + break; + } + + default: { + throw new UserMessageError('Unknown subcommand'); + } + } + }, +}; diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index f58ee75d..00b43506 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -258,7 +258,7 @@ async function handleAutocompleteInteraction(interaction: AutocompleteInteractio } debug(`Calling autocomplete handler for command '${command.info.name}'`); - const options = command.autocomplete(interaction); + const options = await command.autocomplete(interaction); // Return results (limited because of API reasons) // Seriously, Discord WILL throw errors and refuse to deliver ANY diff --git a/src/helpers/tagUtils.test.ts b/src/helpers/tagUtils.test.ts new file mode 100644 index 00000000..e6a69b0b --- /dev/null +++ b/src/helpers/tagUtils.test.ts @@ -0,0 +1,380 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + createTag, + getTag, + getAllTags, + incrementTagUseCount, + deleteTag, + searchTags, + validateImageAttachment, + downloadAttachment, +} from './tagUtils.js'; + +const mockTagModel = vi.hoisted(() => ({ + findUnique: vi.fn(), + findMany: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), +})); + +vi.mock('../database/index.js', () => ({ + db: { tag: mockTagModel }, +})); + +const fakeImageData = Buffer.from('fake-image-data'); + +function createMockTag(overrides: Partial<{ + id: number; + guildId: string; + name: string; + imageData: Buffer; + fileName: string; + contentType: string; + createdBy: string; + createdAt: Date; + useCount: number; +}> = {}) { + return { + id: 1, + guildId: 'guild123', + name: 'test', + imageData: fakeImageData, + fileName: 'image.png', + contentType: 'image/png', + createdBy: 'user123', + createdAt: new Date(), + useCount: 0, + ...overrides, + }; +} + +describe('tagUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('validateImageAttachment', () => { + it('should accept valid image types', () => { + expect(() => validateImageAttachment('image/png', 1024)).not.toThrow(); + expect(() => validateImageAttachment('image/jpeg', 1024)).not.toThrow(); + expect(() => validateImageAttachment('image/gif', 1024)).not.toThrow(); + expect(() => validateImageAttachment('image/webp', 1024)).not.toThrow(); + }); + + it('should reject invalid image types', () => { + expect(() => validateImageAttachment('text/plain', 1024)).toThrow('Invalid image type'); + expect(() => validateImageAttachment('application/pdf', 1024)).toThrow( + 'Invalid image type' + ); + expect(() => validateImageAttachment('video/mp4', 1024)).toThrow('Invalid image type'); + }); + + it('should reject null content type', () => { + expect(() => validateImageAttachment(null, 1024)).toThrow('Invalid image type: unknown'); + }); + + it('should reject images exceeding size limit', () => { + const overLimit = 10 * 1024 * 1024 + 1; + expect(() => validateImageAttachment('image/png', overLimit)).toThrow('too large'); + }); + + it('should accept images at exactly the size limit', () => { + const atLimit = 10 * 1024 * 1024; + expect(() => validateImageAttachment('image/png', atLimit)).not.toThrow(); + }); + }); + + describe('downloadAttachment', () => { + it('should download and return buffer from URL', async () => { + const mockArrayBuffer = new ArrayBuffer(8); + const mockResponse = { + ok: true, + arrayBuffer: () => Promise.resolve(mockArrayBuffer), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as Response); + + const result = await downloadAttachment('https://cdn.discord.com/attachments/test.png'); + + expect(fetch).toHaveBeenCalledWith('https://cdn.discord.com/attachments/test.png'); + expect(result).toBeInstanceOf(Buffer); + expect(result.length).toBe(8); + }); + + it('should throw on failed download', async () => { + const mockResponse = { + ok: false, + status: 404, + statusText: 'Not Found', + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as Response); + + await expect( + downloadAttachment('https://cdn.discord.com/attachments/missing.png') + ).rejects.toThrow('Failed to download attachment: 404 Not Found'); + }); + }); + + describe('createTag', () => { + it('should create a new tag successfully', async () => { + mockTagModel.findUnique.mockResolvedValue(null); + mockTagModel.create.mockResolvedValue(createMockTag()); + + const result = await createTag( + 'guild123', + 'test', + fakeImageData, + 'image.png', + 'image/png', + 'user123' + ); + + expect(result).toBeDefined(); + expect(result.name).toBe('test'); + expect(mockTagModel.findUnique).toHaveBeenCalledWith({ + where: { + guild_tag: { + guildId: 'guild123', + name: 'test', + }, + }, + }); + }); + + it('should throw error if tag already exists', async () => { + mockTagModel.findUnique.mockResolvedValue(createMockTag()); + + await expect( + createTag('guild123', 'test', fakeImageData, 'other.png', 'image/png', 'user456') + ).rejects.toThrow("A tag named 'test' already exists"); + }); + + it('should normalize tag name to lowercase', async () => { + mockTagModel.findUnique.mockResolvedValue(null); + mockTagModel.create.mockResolvedValue(createMockTag({ name: 'testname' })); + + await createTag( + 'guild123', + 'TestName', + fakeImageData, + 'image.png', + 'image/png', + 'user123' + ); + + expect(mockTagModel.create).toHaveBeenCalledWith({ + data: { + guildId: 'guild123', + name: 'testname', + imageData: fakeImageData, + fileName: 'image.png', + contentType: 'image/png', + createdBy: 'user123', + }, + }); + }); + }); + + describe('getTag', () => { + it('should return tag if it exists', async () => { + const tag = createMockTag({ useCount: 5 }); + mockTagModel.findUnique.mockResolvedValue(tag); + + const result = await getTag('guild123', 'test'); + + expect(result).toEqual(tag); + expect(mockTagModel.findUnique).toHaveBeenCalledWith({ + where: { + guild_tag: { + guildId: 'guild123', + name: 'test', + }, + }, + }); + }); + + it('should return null if tag does not exist', async () => { + mockTagModel.findUnique.mockResolvedValue(null); + + const result = await getTag('guild123', 'nonexistent'); + + expect(result).toBeNull(); + }); + }); + + describe('getAllTags', () => { + it('should return all tags for a guild excluding imageData', async () => { + const tagsWithoutImageData = [ + { + id: 1, + guildId: 'guild123', + name: 'tag1', + fileName: 'img1.png', + contentType: 'image/png', + createdBy: 'user1', + createdAt: new Date(), + useCount: 5, + }, + { + id: 2, + guildId: 'guild123', + name: 'tag2', + fileName: 'img2.gif', + contentType: 'image/gif', + createdBy: 'user2', + createdAt: new Date(), + useCount: 10, + }, + ]; + + mockTagModel.findMany.mockResolvedValue(tagsWithoutImageData); + + const result = await getAllTags('guild123'); + + expect(result).toEqual(tagsWithoutImageData); + expect(mockTagModel.findMany).toHaveBeenCalledWith({ + where: { + guildId: 'guild123', + }, + select: { + id: true, + guildId: true, + name: true, + fileName: true, + contentType: true, + createdBy: true, + createdAt: true, + useCount: true, + }, + orderBy: { + name: 'asc', + }, + }); + }); + + it('should return empty array if no tags exist', async () => { + mockTagModel.findMany.mockResolvedValue([]); + + const result = await getAllTags('guild123'); + + expect(result).toEqual([]); + }); + }); + + describe('incrementTagUseCount', () => { + it('should increment use count for tag', async () => { + mockTagModel.update.mockResolvedValue(createMockTag({ useCount: 6 })); + + await incrementTagUseCount('guild123', 'test'); + + expect(mockTagModel.update).toHaveBeenCalledWith({ + where: { + guild_tag: { + guildId: 'guild123', + name: 'test', + }, + }, + data: { + useCount: { + increment: 1, + }, + }, + }); + }); + }); + + describe('deleteTag', () => { + it('should delete tag if user is creator', async () => { + const tag = createMockTag(); + mockTagModel.findUnique.mockResolvedValue(tag); + mockTagModel.delete.mockResolvedValue(tag); + + const result = await deleteTag('guild123', 'test', 'user123'); + + expect(result).toBe(true); + expect(mockTagModel.delete).toHaveBeenCalledWith({ + where: { + guild_tag: { + guildId: 'guild123', + name: 'test', + }, + }, + }); + }); + + it('should return false if tag does not exist', async () => { + mockTagModel.findUnique.mockResolvedValue(null); + + const result = await deleteTag('guild123', 'test', 'user123'); + + expect(result).toBe(false); + expect(mockTagModel.delete).not.toHaveBeenCalled(); + }); + + it('should throw error if user is not creator and not admin', async () => { + mockTagModel.findUnique.mockResolvedValue(createMockTag()); + + await expect(deleteTag('guild123', 'test', 'user456', false)).rejects.toThrow( + 'You can only delete tags you created' + ); + }); + + it('should allow admin to delete any tag', async () => { + const tag = createMockTag(); + mockTagModel.findUnique.mockResolvedValue(tag); + mockTagModel.delete.mockResolvedValue(tag); + + const result = await deleteTag('guild123', 'test', 'admin456', true); + + expect(result).toBe(true); + expect(mockTagModel.delete).toHaveBeenCalledWith({ + where: { + guild_tag: { + guildId: 'guild123', + name: 'test', + }, + }, + }); + }); + }); + + describe('searchTags', () => { + it('should return matching tag names', async () => { + const mockTags = [{ name: 'test1' }, { name: 'test2' }, { name: 'testing' }]; + + mockTagModel.findMany.mockResolvedValue(mockTags); + + const result = await searchTags('guild123', 'test'); + + expect(result).toEqual(['test1', 'test2', 'testing']); + expect(mockTagModel.findMany).toHaveBeenCalledWith({ + where: { + guildId: 'guild123', + name: { + contains: 'test', + }, + }, + select: { + name: true, + }, + orderBy: { + useCount: 'desc', + }, + take: 25, + }); + }); + + it('should respect limit parameter', async () => { + mockTagModel.findMany.mockResolvedValue([]); + + await searchTags('guild123', 'test', 10); + + expect(mockTagModel.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + take: 10, + }) + ); + }); + }); +}); diff --git a/src/helpers/tagUtils.ts b/src/helpers/tagUtils.ts new file mode 100644 index 00000000..dfb9d54b --- /dev/null +++ b/src/helpers/tagUtils.ts @@ -0,0 +1,216 @@ +import { db } from '../database/index.js'; +import type { Tag } from '@prisma/client'; + +const ALLOWED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); +const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB + +/** + * Validates an image attachment's content type and size. + * @throws Error if content type is not allowed or size exceeds limit + */ +export function validateImageAttachment(contentType: string | null, size: number): void { + if (!contentType || !ALLOWED_IMAGE_TYPES.has(contentType)) { + throw new Error( + `Invalid image type: ${contentType ?? 'unknown'}. Allowed types: PNG, JPEG, GIF, WebP.` + ); + } + + if (size > MAX_IMAGE_SIZE_BYTES) { + throw new Error(`Image is too large (${(size / 1024 / 1024).toFixed(1)} MB). Maximum size is 10 MB.`); + } +} + +/** + * Downloads binary data from a URL (e.g. Discord CDN attachment URL). + * @param url URL to download from + * @returns Buffer of the downloaded data + */ +export async function downloadAttachment(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download attachment: ${response.status} ${response.statusText}`); + } + const arrayBuffer = await response.arrayBuffer(); + return Buffer.from(arrayBuffer); +} + +/** + * Creates a new tag for a guild. + * @param guildId Discord guild ID + * @param name Tag name + * @param imageData Binary image data + * @param fileName Original filename + * @param contentType MIME type + * @param createdBy Discord user ID of creator + * @returns The created tag + * @throws Error if tag with that name already exists + */ +export async function createTag( + guildId: string, + name: string, + imageData: Buffer, + fileName: string, + contentType: string, + createdBy: string +): Promise { + // Check if tag already exists + const existing = await db.tag.findUnique({ + where: { + guild_tag: { + guildId, + name: name.toLowerCase(), + }, + }, + }); + + if (existing) { + throw new Error(`A tag named '${name}' already exists in this server.`); + } + + return db.tag.create({ + data: { + guildId, + name: name.toLowerCase(), + imageData, + fileName, + contentType, + createdBy, + }, + }); +} + +/** + * Gets a tag by name for a guild. + * @param guildId Discord guild ID + * @param name Tag name + * @returns The tag or null if not found + */ +export async function getTag(guildId: string, name: string): Promise { + return db.tag.findUnique({ + where: { + guild_tag: { + guildId, + name: name.toLowerCase(), + }, + }, + }); +} + +// TODO: Consider adding image compression before storing (e.g., sharp library) +// to reduce database size. For v1, images are stored at their original size. + +/** + * Gets all tags for a guild, sorted by name. + * Excludes imageData to avoid loading all image binaries into memory. + * @param guildId Discord guild ID + * @returns Array of tags (without imageData) + */ +export async function getAllTags(guildId: string): Promise>> { + return db.tag.findMany({ + where: { + guildId, + }, + select: { + id: true, + guildId: true, + name: true, + fileName: true, + contentType: true, + createdBy: true, + createdAt: true, + useCount: true, + }, + orderBy: { + name: 'asc', + }, + }); +} + +/** + * Increments the use count for a tag. + * @param guildId Discord guild ID + * @param name Tag name + */ +export async function incrementTagUseCount(guildId: string, name: string): Promise { + await db.tag.update({ + where: { + guild_tag: { + guildId, + name: name.toLowerCase(), + }, + }, + data: { + useCount: { + increment: 1, + }, + }, + }); +} + +/** + * Deletes a tag. + * @param guildId Discord guild ID + * @param name Tag name + * @param userId Discord user ID attempting to delete (must be creator or admin) + * @param isAdmin Whether the user is an admin (admins can delete any tag) + * @returns true if deleted, false if not found or unauthorized + */ +export async function deleteTag( + guildId: string, + name: string, + userId: string, + isAdmin = false +): Promise { + const tag = await getTag(guildId, name); + + if (!tag) { + return false; + } + + // Check if user is the creator or an admin + if (!isAdmin && tag.createdBy !== userId) { + throw new Error('You can only delete tags you created. Admins can delete any tag.'); + } + + await db.tag.delete({ + where: { + guild_tag: { + guildId, + name: name.toLowerCase(), + }, + }, + }); + + return true; +} + +/** + * Searches for tags matching a query (for autocomplete). + * @param guildId Discord guild ID + * @param query Search query + * @param limit Maximum number of results + * @returns Array of tag names + */ +export async function searchTags( + guildId: string, + query: string, + limit = 25 +): Promise> { + const tags = await db.tag.findMany({ + where: { + guildId, + name: { + contains: query.toLowerCase(), + }, + }, + select: { + name: true, + }, + orderBy: { + useCount: 'desc', // Prioritize frequently used tags + }, + take: limit, + }); + + return tags.map(tag => tag.name); +}