From 1072c5d34d9d80e359585b68affe7718b9344384 Mon Sep 17 00:00:00 2001 From: Zack Yancey Date: Thu, 23 Oct 2025 12:11:22 -0600 Subject: [PATCH 1/4] feat: add tag command with CRUD operations and database schema --- .../migration.sql | 16 + prisma/schema.prisma | 13 + src/@types/Command.d.ts | 2 +- src/commands/index.ts | 4 +- src/commands/sendtag.test.ts | 44 -- src/commands/sendtag.ts | 64 --- src/commands/tag.test.ts | 429 ++++++++++++++++++ src/commands/tag.ts | 296 ++++++++++++ src/events/interactionCreate.ts | 2 +- src/helpers/tagUtils.test.ts | 328 +++++++++++++ src/helpers/tagUtils.ts | 159 +++++++ src/roomFinder/index.ts | 4 - 12 files changed, 1245 insertions(+), 116 deletions(-) create mode 100644 prisma/migrations/20251023174246_add_tag_model/migration.sql delete mode 100644 src/commands/sendtag.test.ts delete mode 100644 src/commands/sendtag.ts create mode 100644 src/commands/tag.test.ts create mode 100644 src/commands/tag.ts create mode 100644 src/helpers/tagUtils.test.ts create mode 100644 src/helpers/tagUtils.ts diff --git a/prisma/migrations/20251023174246_add_tag_model/migration.sql b/prisma/migrations/20251023174246_add_tag_model/migration.sql new file mode 100644 index 00000000..deec6a26 --- /dev/null +++ b/prisma/migrations/20251023174246_add_tag_model/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "Tag" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "guildId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "content" TEXT NOT NULL, + "createdBy" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "useCount" INTEGER NOT NULL DEFAULT 0 +); + +-- CreateIndex +CREATE INDEX "Tag_guildId_idx" ON "Tag"("guildId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Tag_guildId_name_key" ON "Tag"("guildId", "name"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2b7fda23..94877c69 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -76,3 +76,16 @@ model User { @@unique([userId, guildId], name: "user_guild") } + +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 + + @@unique([guildId, name], name: "guild_tag") + @@index([guildId]) +} diff --git a/src/@types/Command.d.ts b/src/@types/Command.d.ts index 48c22777..2eea9bd7 100644 --- a/src/@types/Command.d.ts +++ b/src/@types/Command.d.ts @@ -30,7 +30,7 @@ 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..acc2257a --- /dev/null +++ b/src/commands/tag.test.ts @@ -0,0 +1,429 @@ +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'); + +describe('tag command', () => { + const mockReply = vi.fn(); + const mockGetString = vi.fn(); + const mockGetSubcommand = vi.fn(); + + let context: GuildedCommandContext; + + beforeEach(() => { + vi.clearAllMocks(); + + context = { + reply: mockReply, + options: { + getString: mockGetString, + getSubcommand: mockGetSubcommand, + 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 embedded image for image URLs', async () => { + mockGetString.mockImplementation((name: string) => { + if (name === 'name') return 'testtag'; + if (name === 'content') return 'https://example.com/image.png'; + return null; + }); + + vi.mocked(tagUtils.createTag).mockResolvedValue({ + id: 1, + guildId: 'guild123', + name: 'testtag', + content: 'https://example.com/image.png', + createdBy: 'user123', + createdAt: new Date(), + useCount: 0, + }); + + await tag.execute(context); + + expect(tagUtils.createTag).toHaveBeenCalledWith( + 'guild123', + 'testtag', + 'https://example.com/image.png', + 'user123' + ); + expect(mockReply).toHaveBeenCalledWith( + expect.objectContaining({ + ephemeral: true, + embeds: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + title: '✅ Tag Created', + image: expect.objectContaining({ + url: 'https://example.com/image.png', + }), + }), + }), + ]), + }) + ); + }); + + it('should create a tag with text field for non-image content', async () => { + mockGetString.mockImplementation((name: string) => { + if (name === 'name') return 'texttag'; + if (name === 'content') return 'Just some text content'; + return null; + }); + + vi.mocked(tagUtils.createTag).mockResolvedValue({ + id: 1, + guildId: 'guild123', + name: 'texttag', + content: 'Just some text content', + createdBy: 'user123', + createdAt: new Date(), + useCount: 0, + }); + + await tag.execute(context); + + expect(mockReply).toHaveBeenCalledWith( + expect.objectContaining({ + ephemeral: true, + embeds: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + title: '✅ Tag Created', + fields: expect.arrayContaining([ + expect.objectContaining({ + name: 'Content', + value: 'Just some text content', + }), + ]), + }), + }), + ]), + }) + ); + }); + + it('should handle duplicate tag name error', async () => { + mockGetString.mockImplementation((name: string) => { + if (name === 'name') return 'existing'; + if (name === 'content') return 'https://example.com/image.png'; + return null; + }); + + 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 URLs as embeds', async () => { + mockGetString.mockReturnValue('testtag'); + + vi.mocked(tagUtils.getTag).mockResolvedValue({ + id: 1, + guildId: 'guild123', + name: 'testtag', + content: 'https://example.com/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({ + embeds: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + image: expect.objectContaining({ + url: 'https://example.com/image.png', + }), + }), + }), + ]), + }) + ); + }); + + it('should send non-image content as plain text', async () => { + mockGetString.mockReturnValue('texttag'); + + vi.mocked(tagUtils.getTag).mockResolvedValue({ + id: 2, + guildId: 'guild123', + name: 'texttag', + content: 'Just some text or a non-image URL', + createdBy: 'user123', + createdAt: new Date(), + useCount: 2, + }); + + vi.mocked(tagUtils.incrementTagUseCount).mockResolvedValue(); + + await tag.execute(context); + + expect(mockReply).toHaveBeenCalledWith({ + content: 'Just some text or a non-image URL', + }); + }); + + 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 embedded image', async () => { + mockGetString.mockReturnValue('testtag'); + + const mockDate = new Date('2023-01-01'); + vi.mocked(tagUtils.getTag).mockResolvedValue({ + id: 1, + guildId: 'guild123', + name: 'testtag', + content: 'https://example.com/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: 'https://example.com/image.png', + }), + }), + }), + ]), + }) + ); + }); + + it('should show text preview with description', async () => { + mockGetString.mockReturnValue('texttag'); + + const mockDate = new Date('2023-01-01'); + vi.mocked(tagUtils.getTag).mockResolvedValue({ + id: 2, + guildId: 'guild123', + name: 'texttag', + content: 'Just some text content', + createdBy: 'user123', + createdAt: mockDate, + useCount: 5, + }); + + await tag.execute(context); + + expect(mockReply).toHaveBeenCalledWith( + expect.objectContaining({ + ephemeral: true, + embeds: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + title: 'Preview: texttag', + description: 'Just some text content', + }), + }), + ]), + }) + ); + }); + + 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 () => { + vi.mocked(tagUtils.getAllTags).mockResolvedValue([ + { + id: 1, + guildId: 'guild123', + name: 'tag1', + content: 'content1', + createdBy: 'user1', + createdAt: new Date(), + useCount: 5, + }, + { + id: 2, + guildId: 'guild123', + name: 'tag2', + content: 'content2', + 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 result = await tag.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 result = await tag.autocomplete?.(mockInteraction); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/src/commands/tag.ts b/src/commands/tag.ts new file mode 100644 index 00000000..112cfcb1 --- /dev/null +++ b/src/commands/tag.ts @@ -0,0 +1,296 @@ +import { EmbedBuilder, SlashCommandBuilder } from 'discord.js'; +import { + createTag, + getTag, + getAllTags, + incrementTagUseCount, + deleteTag, + searchTags, +} from '../helpers/tagUtils.js'; +import { UserMessageError } from '../helpers/UserMessageError.js'; +import { isAdmin } from '../helpers/smiteUtils.js'; + +const NameOption = 'name'; +const ContentOption = 'content'; + +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/links') + .addSubcommand(subcommand => + subcommand + .setName(AddSubcommand) + .setDescription('Create a new tag') + .addStringOption(option => + option + .setName(NameOption) + .setDescription('The name of the tag') + .setRequired(true) + .setMaxLength(100) + ) + .addStringOption(option => + option + .setName(ContentOption) + .setDescription('The URL or text content for the tag') + .setRequired(true) + .setMaxLength(2000) + ) + ) + .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 content = options.getString(ContentOption, true); + + try { + await createTag(guild.id, name, content, user.id); + + // Check if content is an image URL + const imageUrlRegex = /^https?:\/\/.+\.(jpg|jpeg|png|gif|webp|bmp|svg|ico)(\?.*)?$/i; + const isImage = imageUrlRegex.test(content); + + const embed = new EmbedBuilder() + .setTitle('✅ Tag Created') + .setDescription(`Tag \`${name}\` has been created!`) + .setColor(0x00ff00) // Green + .setFooter({ text: `Created by ${user.username}` }); + + // If it's an image, display it; otherwise show as text field + if (isImage) { + embed.setImage(content); + } else { + embed.addFields({ name: 'Content', value: content }); + } + + await reply({ + embeds: [embed], + 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); + + // Check if content is an image URL + const imageUrlRegex = /^https?:\/\/.+\.(jpg|jpeg|png|gif|webp|bmp|svg|ico)(\?.*)?$/i; + if (imageUrlRegex.test(tagData.content)) { + // Send as embed with image + await reply({ + embeds: [ + new EmbedBuilder() + .setImage(tagData.content) + .setColor(0x5865f2), // Blurple + ], + }); + } else { + // Send as plain text (for non-image URLs or text) + await reply({ content: tagData.content }); + } + 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.` + ); + } + + // Check if content is an image URL + const imageUrlRegex = /^https?:\/\/.+\.(jpg|jpeg|png|gif|webp|bmp|svg|ico)(\?.*)?$/i; + const isImage = imageUrlRegex.test(tagData.content); + + const embed = new EmbedBuilder() + .setTitle(`Preview: ${name}`) + .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, + } + ) + .setColor(0x5865f2); // Blurple + + // If it's an image, display it; otherwise show as text + if (isImage) { + embed.setImage(tagData.content); + } else { + embed.setDescription(tagData.content); + } + + await reply({ + embeds: [embed], + 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(0xffa500), // Orange + ], + ephemeral: true, + }); + return; + } + + // Split into multiple embeds if there are too many tags + const maxFieldsPerEmbed = 25; + const embeds: EmbedBuilder[] = []; + + 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(0x5865f2); // 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(0xff0000), // 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 fddb36c2..842b6d28 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..bd24c64f --- /dev/null +++ b/src/helpers/tagUtils.test.ts @@ -0,0 +1,328 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PrismaClient } from '@prisma/client'; +import { + createTag, + getTag, + getAllTags, + incrementTagUseCount, + deleteTag, + searchTags, +} from './tagUtils.js'; +import { db } from '../database/index.js'; + +vi.mock('../database/index.js', () => ({ + db: new PrismaClient(), +})); + +describe('tagUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('createTag', () => { + it('should create a new tag successfully', async () => { + vi.spyOn(db.tag, 'findUnique').mockResolvedValue(null); + vi.spyOn(db.tag, 'create').mockResolvedValue({ + id: 1, + guildId: 'guild123', + name: 'test', + content: 'https://example.com/image.png', + createdBy: 'user123', + createdAt: new Date(), + useCount: 0, + }); + + const result = await createTag('guild123', 'test', 'https://example.com/image.png', 'user123'); + + expect(result).toBeDefined(); + expect(result.name).toBe('test'); + expect(db.tag.findUnique).toHaveBeenCalledWith({ + where: { + guild_tag: { + guildId: 'guild123', + name: 'test', + }, + }, + }); + }); + + it('should throw error if tag already exists', async () => { + vi.spyOn(db.tag, 'findUnique').mockResolvedValue({ + id: 1, + guildId: 'guild123', + name: 'test', + content: 'https://example.com/image.png', + createdBy: 'user123', + createdAt: new Date(), + useCount: 0, + }); + + await expect( + createTag('guild123', 'test', 'https://example.com/other.png', 'user456') + ).rejects.toThrow("A tag named 'test' already exists"); + }); + + it('should normalize tag name to lowercase', async () => { + vi.spyOn(db.tag, 'findUnique').mockResolvedValue(null); + vi.spyOn(db.tag, 'create').mockResolvedValue({ + id: 1, + guildId: 'guild123', + name: 'testname', + content: 'https://example.com/image.png', + createdBy: 'user123', + createdAt: new Date(), + useCount: 0, + }); + + await createTag('guild123', 'TestName', 'https://example.com/image.png', 'user123'); + + expect(db.tag.create).toHaveBeenCalledWith({ + data: { + guildId: 'guild123', + name: 'testname', + content: 'https://example.com/image.png', + createdBy: 'user123', + }, + }); + }); + }); + + describe('getTag', () => { + it('should return tag if it exists', async () => { + const mockTag = { + id: 1, + guildId: 'guild123', + name: 'test', + content: 'https://example.com/image.png', + createdBy: 'user123', + createdAt: new Date(), + useCount: 5, + }; + + vi.spyOn(db.tag, 'findUnique').mockResolvedValue(mockTag); + + const result = await getTag('guild123', 'test'); + + expect(result).toEqual(mockTag); + expect(db.tag.findUnique).toHaveBeenCalledWith({ + where: { + guild_tag: { + guildId: 'guild123', + name: 'test', + }, + }, + }); + }); + + it('should return null if tag does not exist', async () => { + vi.spyOn(db.tag, 'findUnique').mockResolvedValue(null); + + const result = await getTag('guild123', 'nonexistent'); + + expect(result).toBeNull(); + }); + }); + + describe('getAllTags', () => { + it('should return all tags for a guild', async () => { + const mockTags = [ + { + id: 1, + guildId: 'guild123', + name: 'tag1', + content: 'content1', + createdBy: 'user1', + createdAt: new Date(), + useCount: 5, + }, + { + id: 2, + guildId: 'guild123', + name: 'tag2', + content: 'content2', + createdBy: 'user2', + createdAt: new Date(), + useCount: 10, + }, + ]; + + vi.spyOn(db.tag, 'findMany').mockResolvedValue(mockTags); + + const result = await getAllTags('guild123'); + + expect(result).toEqual(mockTags); + expect(db.tag.findMany).toHaveBeenCalledWith({ + where: { + guildId: 'guild123', + }, + orderBy: { + name: 'asc', + }, + }); + }); + + it('should return empty array if no tags exist', async () => { + vi.spyOn(db.tag, 'findMany').mockResolvedValue([]); + + const result = await getAllTags('guild123'); + + expect(result).toEqual([]); + }); + }); + + describe('incrementTagUseCount', () => { + it('should increment use count for tag', async () => { + vi.spyOn(db.tag, 'update').mockResolvedValue({ + id: 1, + guildId: 'guild123', + name: 'test', + content: 'content', + createdBy: 'user123', + createdAt: new Date(), + useCount: 6, + }); + + await incrementTagUseCount('guild123', 'test'); + + expect(db.tag.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 mockTag = { + id: 1, + guildId: 'guild123', + name: 'test', + content: 'content', + createdBy: 'user123', + createdAt: new Date(), + useCount: 0, + }; + + vi.spyOn(db.tag, 'findUnique').mockResolvedValue(mockTag); + vi.spyOn(db.tag, 'delete').mockResolvedValue(mockTag); + + const result = await deleteTag('guild123', 'test', 'user123'); + + expect(result).toBe(true); + expect(db.tag.delete).toHaveBeenCalledWith({ + where: { + guild_tag: { + guildId: 'guild123', + name: 'test', + }, + }, + }); + }); + + it('should return false if tag does not exist', async () => { + vi.spyOn(db.tag, 'findUnique').mockResolvedValue(null); + + const result = await deleteTag('guild123', 'test', 'user123'); + + expect(result).toBe(false); + expect(db.tag.delete).not.toHaveBeenCalled(); + }); + + it('should throw error if user is not creator and not admin', async () => { + const mockTag = { + id: 1, + guildId: 'guild123', + name: 'test', + content: 'content', + createdBy: 'user123', + createdAt: new Date(), + useCount: 0, + }; + + vi.spyOn(db.tag, 'findUnique').mockResolvedValue(mockTag); + + 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 mockTag = { + id: 1, + guildId: 'guild123', + name: 'test', + content: 'content', + createdBy: 'user123', + createdAt: new Date(), + useCount: 0, + }; + + vi.spyOn(db.tag, 'findUnique').mockResolvedValue(mockTag); + vi.spyOn(db.tag, 'delete').mockResolvedValue(mockTag); + + const result = await deleteTag('guild123', 'test', 'admin456', true); + + expect(result).toBe(true); + expect(db.tag.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' }, + ]; + + vi.spyOn(db.tag, 'findMany').mockResolvedValue(mockTags as any); + + const result = await searchTags('guild123', 'test'); + + expect(result).toEqual(['test1', 'test2', 'testing']); + expect(db.tag.findMany).toHaveBeenCalledWith({ + where: { + guildId: 'guild123', + name: { + contains: 'test', + }, + }, + select: { + name: true, + }, + orderBy: { + useCount: 'desc', + }, + take: 25, + }); + }); + + it('should respect limit parameter', async () => { + vi.spyOn(db.tag, 'findMany').mockResolvedValue([]); + + await searchTags('guild123', 'test', 10); + + expect(db.tag.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + take: 10, + }) + ); + }); + }); +}); diff --git a/src/helpers/tagUtils.ts b/src/helpers/tagUtils.ts new file mode 100644 index 00000000..8d2e74ad --- /dev/null +++ b/src/helpers/tagUtils.ts @@ -0,0 +1,159 @@ +import { db } from '../database/index.js'; +import type { Tag } from '@prisma/client'; + +/** + * Creates a new tag for a guild. + * @param guildId Discord guild ID + * @param name Tag name + * @param content Tag content (URL or text) + * @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, + content: 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(), + content, + 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(), + }, + }, + }); +} + +/** + * Gets all tags for a guild, sorted by name. + * @param guildId Discord guild ID + * @returns Array of tags + */ +export async function getAllTags(guildId: string): Promise { + return db.tag.findMany({ + where: { + guildId, + }, + 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); +} diff --git a/src/roomFinder/index.ts b/src/roomFinder/index.ts index 85c78d9c..ee1a08dd 100644 --- a/src/roomFinder/index.ts +++ b/src/roomFinder/index.ts @@ -4,13 +4,9 @@ export * from './types.js'; export * from './search.js'; export * from './utils.js'; -export * from './init.js'; // Main lookup function for finding available rooms export { lookup } from './search.js'; // Utility functions export * from './utils.js'; - -// Database initialization functions -export * from './init.js'; From 227b5fad2333009d87f4a0dd382fb901f5a941fd Mon Sep 17 00:00:00 2001 From: Zack Yancey Date: Thu, 23 Oct 2025 13:32:52 -0600 Subject: [PATCH 2/4] feat: update readme and version --- README.md | 16 ++++++++++++---- package.json | 2 +- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ce30d79e..136d7def 100644 --- a/README.md +++ b/README.md @@ -92,10 +92,6 @@ Retrieves the profile picture of the given user. **[Admin Only]** 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 4f7f0156..cccc9492 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": [ From 92a8735fab2aab82e924edbfd7e37f38a7ee2d0f Mon Sep 17 00:00:00 2001 From: Zack Yancey Date: Fri, 24 Oct 2025 16:44:50 -0600 Subject: [PATCH 3/4] style: lint cleanup --- src/@types/Command.d.ts | 4 +++- src/commands/tag.test.ts | 11 +++++++++-- src/commands/tag.ts | 23 ++++++++++------------- src/helpers/tagUtils.test.ts | 15 +++++++++------ src/helpers/tagUtils.ts | 8 ++++++-- 5 files changed, 37 insertions(+), 24 deletions(-) diff --git a/src/@types/Command.d.ts b/src/@types/Command.d.ts index 2eea9bd7..ba9b1cb3 100644 --- a/src/@types/Command.d.ts +++ b/src/@types/Command.d.ts @@ -30,7 +30,9 @@ declare global { */ autocomplete?: ( interaction: Omit - ) => Array | Promise>; + ) => + | Array + | Promise>; } interface GlobalCommand extends BaseCommand { diff --git a/src/commands/tag.test.ts b/src/commands/tag.test.ts index acc2257a..cfac370f 100644 --- a/src/commands/tag.test.ts +++ b/src/commands/tag.test.ts @@ -1,3 +1,4 @@ +/* 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'; @@ -403,7 +404,10 @@ describe('tag command', () => { vi.mocked(tagUtils.searchTags).mockResolvedValue(['test1', 'test2', 'testing']); - const result = await tag.autocomplete?.(mockInteraction); + const autocomplete = tag.autocomplete as ( + interaction: AutocompleteInteraction + ) => Promise>; + const result = await autocomplete(mockInteraction); expect(tagUtils.searchTags).toHaveBeenCalledWith('guild123', 'test'); expect(result).toEqual([ @@ -421,7 +425,10 @@ describe('tag command', () => { }, } as unknown as AutocompleteInteraction; - const result = await tag.autocomplete?.(mockInteraction); + 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 index 112cfcb1..c790fa36 100644 --- a/src/commands/tag.ts +++ b/src/commands/tag.ts @@ -111,7 +111,7 @@ export const tag: GuildedCommand = { const embed = new EmbedBuilder() .setTitle('✅ Tag Created') .setDescription(`Tag \`${name}\` has been created!`) - .setColor(0x00ff00) // Green + .setColor(0x00_ff_00) // Green .setFooter({ text: `Created by ${user.username}` }); // If it's an image, display it; otherwise show as text field @@ -153,9 +153,7 @@ export const tag: GuildedCommand = { // Send as embed with image await reply({ embeds: [ - new EmbedBuilder() - .setImage(tagData.content) - .setColor(0x5865f2), // Blurple + new EmbedBuilder().setImage(tagData.content).setColor(0x58_65_f2), // Blurple ], }); } else { @@ -198,7 +196,7 @@ export const tag: GuildedCommand = { inline: true, } ) - .setColor(0x5865f2); // Blurple + .setColor(0x58_65_f2); // Blurple // If it's an image, display it; otherwise show as text if (isImage) { @@ -222,10 +220,8 @@ export const tag: GuildedCommand = { embeds: [ new EmbedBuilder() .setTitle('📋 Tags') - .setDescription( - 'No tags available in this server.\n\nCreate one with `/tag add`!' - ) - .setColor(0xffa500), // Orange + .setDescription('No tags available in this server.\n\nCreate one with `/tag add`!') + .setColor(0xff_a5_00), // Orange ], ephemeral: true, }); @@ -234,13 +230,13 @@ export const tag: GuildedCommand = { // Split into multiple embeds if there are too many tags const maxFieldsPerEmbed = 25; - const embeds: EmbedBuilder[] = []; + 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(0x5865f2); // Blurple + .setColor(0x58_65_f2); // Blurple for (const tagData of chunk) { embed.addFields({ @@ -276,7 +272,7 @@ export const tag: GuildedCommand = { new EmbedBuilder() .setTitle('🗑️ Tag Removed') .setDescription(`Tag \`${name}\` has been deleted.`) - .setColor(0xff0000), // Red + .setColor(0xff_00_00), // Red ], ephemeral: true, }); @@ -289,8 +285,9 @@ export const tag: GuildedCommand = { break; } - default: + default: { throw new UserMessageError('Unknown subcommand'); + } } }, }; diff --git a/src/helpers/tagUtils.test.ts b/src/helpers/tagUtils.test.ts index bd24c64f..f1838422 100644 --- a/src/helpers/tagUtils.test.ts +++ b/src/helpers/tagUtils.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/unbound-method */ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { PrismaClient } from '@prisma/client'; import { @@ -32,7 +33,12 @@ describe('tagUtils', () => { useCount: 0, }); - const result = await createTag('guild123', 'test', 'https://example.com/image.png', 'user123'); + const result = await createTag( + 'guild123', + 'test', + 'https://example.com/image.png', + 'user123' + ); expect(result).toBeDefined(); expect(result.name).toBe('test'); @@ -285,12 +291,9 @@ describe('tagUtils', () => { describe('searchTags', () => { it('should return matching tag names', async () => { - const mockTags = [ - { name: 'test1' }, - { name: 'test2' }, - { name: 'testing' }, - ]; + const mockTags = [{ name: 'test1' }, { name: 'test2' }, { name: 'testing' }]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any vi.spyOn(db.tag, 'findMany').mockResolvedValue(mockTags as any); const result = await searchTags('guild123', 'test'); diff --git a/src/helpers/tagUtils.ts b/src/helpers/tagUtils.ts index 8d2e74ad..049ec3be 100644 --- a/src/helpers/tagUtils.ts +++ b/src/helpers/tagUtils.ts @@ -62,7 +62,7 @@ export async function getTag(guildId: string, name: string): Promise * @param guildId Discord guild ID * @returns Array of tags */ -export async function getAllTags(guildId: string): Promise { +export async function getAllTags(guildId: string): Promise> { return db.tag.findMany({ where: { guildId, @@ -138,7 +138,11 @@ export async function deleteTag( * @param limit Maximum number of results * @returns Array of tag names */ -export async function searchTags(guildId: string, query: string, limit = 25): Promise { +export async function searchTags( + guildId: string, + query: string, + limit = 25 +): Promise> { const tags = await db.tag.findMany({ where: { guildId, From 58773a71da0dc94c58976eaff4eb51b3cba9a71d Mon Sep 17 00:00:00 2001 From: Zack Yancey Date: Sun, 1 Feb 2026 21:04:53 -0700 Subject: [PATCH 4/4] Reworks tag system to use the file upload process and stores tag contents in DB --- .../migration.sql | 4 +- prisma/schema.prisma | 16 +- src/commands/tag.test.ts | 177 ++++------- src/commands/tag.ts | 90 +++--- src/helpers/tagUtils.test.ts | 277 +++++++++++------- src/helpers/tagUtils.ts | 63 +++- 6 files changed, 334 insertions(+), 293 deletions(-) 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/commands/tag.test.ts b/src/commands/tag.test.ts index cfac370f..97c14b8d 100644 --- a/src/commands/tag.test.ts +++ b/src/commands/tag.test.ts @@ -6,21 +6,30 @@ 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: { @@ -49,18 +58,26 @@ describe('tag command', () => { mockGetSubcommand.mockReturnValue('add'); }); - it('should create a tag with embedded image for image URLs', async () => { + it('should create a tag with an image attachment', async () => { mockGetString.mockImplementation((name: string) => { if (name === 'name') return 'testtag'; - if (name === 'content') return 'https://example.com/image.png'; 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', - content: 'https://example.com/image.png', + imageData: fakeImageData, + fileName: 'photo.png', + contentType: 'image/png', createdBy: 'user123', createdAt: new Date(), useCount: 0, @@ -68,10 +85,16 @@ describe('tag command', () => { 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', - 'https://example.com/image.png', + fakeImageData, + 'photo.png', + 'image/png', 'user123' ); expect(mockReply).toHaveBeenCalledWith( @@ -80,52 +103,11 @@ describe('tag command', () => { embeds: expect.arrayContaining([ expect.objectContaining({ data: expect.objectContaining({ - title: '✅ Tag Created', - image: expect.objectContaining({ - url: 'https://example.com/image.png', - }), - }), - }), - ]), - }) - ); - }); - - it('should create a tag with text field for non-image content', async () => { - mockGetString.mockImplementation((name: string) => { - if (name === 'name') return 'texttag'; - if (name === 'content') return 'Just some text content'; - return null; - }); - - vi.mocked(tagUtils.createTag).mockResolvedValue({ - id: 1, - guildId: 'guild123', - name: 'texttag', - content: 'Just some text content', - createdBy: 'user123', - createdAt: new Date(), - useCount: 0, - }); - - await tag.execute(context); - - expect(mockReply).toHaveBeenCalledWith( - expect.objectContaining({ - ephemeral: true, - embeds: expect.arrayContaining([ - expect.objectContaining({ - data: expect.objectContaining({ - title: '✅ Tag Created', - fields: expect.arrayContaining([ - expect.objectContaining({ - name: 'Content', - value: 'Just some text content', - }), - ]), + title: 'Tag Created', }), }), ]), + files: expect.any(Array), }) ); }); @@ -133,10 +115,16 @@ describe('tag command', () => { it('should handle duplicate tag name error', async () => { mockGetString.mockImplementation((name: string) => { if (name === 'name') return 'existing'; - if (name === 'content') return 'https://example.com/image.png'; 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.") ); @@ -150,14 +138,16 @@ describe('tag command', () => { mockGetSubcommand.mockReturnValue('send'); }); - it('should send image URLs as embeds', async () => { + it('should send image as file attachment', async () => { mockGetString.mockReturnValue('testtag'); vi.mocked(tagUtils.getTag).mockResolvedValue({ id: 1, guildId: 'guild123', name: 'testtag', - content: 'https://example.com/image.png', + imageData: fakeImageData, + fileName: 'photo.png', + contentType: 'image/png', createdBy: 'user123', createdAt: new Date(), useCount: 5, @@ -171,41 +161,11 @@ describe('tag command', () => { expect(tagUtils.incrementTagUseCount).toHaveBeenCalledWith('guild123', 'testtag'); expect(mockReply).toHaveBeenCalledWith( expect.objectContaining({ - embeds: expect.arrayContaining([ - expect.objectContaining({ - data: expect.objectContaining({ - image: expect.objectContaining({ - url: 'https://example.com/image.png', - }), - }), - }), - ]), + files: expect.any(Array), }) ); }); - it('should send non-image content as plain text', async () => { - mockGetString.mockReturnValue('texttag'); - - vi.mocked(tagUtils.getTag).mockResolvedValue({ - id: 2, - guildId: 'guild123', - name: 'texttag', - content: 'Just some text or a non-image URL', - createdBy: 'user123', - createdAt: new Date(), - useCount: 2, - }); - - vi.mocked(tagUtils.incrementTagUseCount).mockResolvedValue(); - - await tag.execute(context); - - expect(mockReply).toHaveBeenCalledWith({ - content: 'Just some text or a non-image URL', - }); - }); - it('should throw error if tag not found', async () => { mockGetString.mockReturnValue('nonexistent'); vi.mocked(tagUtils.getTag).mockResolvedValue(null); @@ -219,7 +179,7 @@ describe('tag command', () => { mockGetSubcommand.mockReturnValue('preview'); }); - it('should show image preview with embedded image', async () => { + it('should show image preview with metadata', async () => { mockGetString.mockReturnValue('testtag'); const mockDate = new Date('2023-01-01'); @@ -227,7 +187,9 @@ describe('tag command', () => { id: 1, guildId: 'guild123', name: 'testtag', - content: 'https://example.com/image.png', + imageData: fakeImageData, + fileName: 'photo.png', + contentType: 'image/png', createdBy: 'user123', createdAt: mockDate, useCount: 10, @@ -244,42 +206,18 @@ describe('tag command', () => { data: expect.objectContaining({ title: 'Preview: testtag', image: expect.objectContaining({ - url: 'https://example.com/image.png', + url: 'attachment://photo.png', }), + fields: expect.arrayContaining([ + expect.objectContaining({ + name: 'File', + value: 'photo.png', + }), + ]), }), }), ]), - }) - ); - }); - - it('should show text preview with description', async () => { - mockGetString.mockReturnValue('texttag'); - - const mockDate = new Date('2023-01-01'); - vi.mocked(tagUtils.getTag).mockResolvedValue({ - id: 2, - guildId: 'guild123', - name: 'texttag', - content: 'Just some text content', - createdBy: 'user123', - createdAt: mockDate, - useCount: 5, - }); - - await tag.execute(context); - - expect(mockReply).toHaveBeenCalledWith( - expect.objectContaining({ - ephemeral: true, - embeds: expect.arrayContaining([ - expect.objectContaining({ - data: expect.objectContaining({ - title: 'Preview: texttag', - description: 'Just some text content', - }), - }), - ]), + files: expect.any(Array), }) ); }); @@ -298,12 +236,14 @@ describe('tag command', () => { }); 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', - content: 'content1', + fileName: 'img1.png', + contentType: 'image/png', createdBy: 'user1', createdAt: new Date(), useCount: 5, @@ -312,7 +252,8 @@ describe('tag command', () => { id: 2, guildId: 'guild123', name: 'tag2', - content: 'content2', + fileName: 'img2.gif', + contentType: 'image/gif', createdBy: 'user2', createdAt: new Date(), useCount: 10, @@ -368,7 +309,7 @@ describe('tag command', () => { embeds: expect.arrayContaining([ expect.objectContaining({ data: expect.objectContaining({ - title: '🗑️ Tag Removed', + title: 'Tag Removed', }), }), ]), diff --git a/src/commands/tag.ts b/src/commands/tag.ts index c790fa36..1cfaf2ec 100644 --- a/src/commands/tag.ts +++ b/src/commands/tag.ts @@ -1,4 +1,4 @@ -import { EmbedBuilder, SlashCommandBuilder } from 'discord.js'; +import { AttachmentBuilder, EmbedBuilder, SlashCommandBuilder } from 'discord.js'; import { createTag, getTag, @@ -6,12 +6,14 @@ import { 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 ContentOption = 'content'; +const ImageOption = 'image'; const AddSubcommand = 'add'; const SendSubcommand = 'send'; @@ -21,11 +23,11 @@ const RemoveSubcommand = 'remove'; const builder = new SlashCommandBuilder() .setName('tag') - .setDescription('Manage and use tagged images/links') + .setDescription('Manage and use tagged images') .addSubcommand(subcommand => subcommand .setName(AddSubcommand) - .setDescription('Create a new tag') + .setDescription('Create a new tag from an image') .addStringOption(option => option .setName(NameOption) @@ -33,12 +35,11 @@ const builder = new SlashCommandBuilder() .setRequired(true) .setMaxLength(100) ) - .addStringOption(option => + .addAttachmentOption(option => option - .setName(ContentOption) - .setDescription('The URL or text content for the tag') + .setName(ImageOption) + .setDescription('The image to store (PNG, JPEG, GIF, WebP, max 10 MB)') .setRequired(true) - .setMaxLength(2000) ) ) .addSubcommand(subcommand => @@ -99,30 +100,31 @@ export const tag: GuildedCommand = { switch (subcommand) { case AddSubcommand: { const name = options.getString(NameOption, true); - const content = options.getString(ContentOption, true); + const attachment = options.getAttachment(ImageOption, true); try { - await createTag(guild.id, name, content, user.id); - - // Check if content is an image URL - const imageUrlRegex = /^https?:\/\/.+\.(jpg|jpeg|png|gif|webp|bmp|svg|ico)(\?.*)?$/i; - const isImage = imageUrlRegex.test(content); + 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') + .setTitle('Tag Created') .setDescription(`Tag \`${name}\` has been created!`) + .setImage(`attachment://${attachment.name}`) .setColor(0x00_ff_00) // Green .setFooter({ text: `Created by ${user.username}` }); - // If it's an image, display it; otherwise show as text field - if (isImage) { - embed.setImage(content); - } else { - embed.addFields({ name: 'Content', value: content }); - } - await reply({ embeds: [embed], + files: [file], ephemeral: true, }); } catch (error) { @@ -147,19 +149,11 @@ export const tag: GuildedCommand = { // Increment use count await incrementTagUseCount(guild.id, name); - // Check if content is an image URL - const imageUrlRegex = /^https?:\/\/.+\.(jpg|jpeg|png|gif|webp|bmp|svg|ico)(\?.*)?$/i; - if (imageUrlRegex.test(tagData.content)) { - // Send as embed with image - await reply({ - embeds: [ - new EmbedBuilder().setImage(tagData.content).setColor(0x58_65_f2), // Blurple - ], - }); - } else { - // Send as plain text (for non-image URLs or text) - await reply({ content: tagData.content }); - } + const file = new AttachmentBuilder(Buffer.from(tagData.imageData), { + name: tagData.fileName, + }); + + await reply({ files: [file] }); break; } @@ -173,12 +167,13 @@ export const tag: GuildedCommand = { ); } - // Check if content is an image URL - const imageUrlRegex = /^https?:\/\/.+\.(jpg|jpeg|png|gif|webp|bmp|svg|ico)(\?.*)?$/i; - const isImage = imageUrlRegex.test(tagData.content); + 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', @@ -194,19 +189,18 @@ export const tag: GuildedCommand = { name: 'Created', value: tagData.createdAt.toLocaleDateString(), inline: true, + }, + { + name: 'File', + value: tagData.fileName, + inline: true, } ) .setColor(0x58_65_f2); // Blurple - // If it's an image, display it; otherwise show as text - if (isImage) { - embed.setImage(tagData.content); - } else { - embed.setDescription(tagData.content); - } - await reply({ embeds: [embed], + files: [file], ephemeral: true, }); break; @@ -219,7 +213,7 @@ export const tag: GuildedCommand = { await reply({ embeds: [ new EmbedBuilder() - .setTitle('📋 Tags') + .setTitle('Tags') .setDescription('No tags available in this server.\n\nCreate one with `/tag add`!') .setColor(0xff_a5_00), // Orange ], @@ -235,7 +229,7 @@ export const tag: GuildedCommand = { 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)') + .setTitle(i === 0 ? `Tags (${tags.length} total)` : 'Tags (continued)') .setColor(0x58_65_f2); // Blurple for (const tagData of chunk) { @@ -270,7 +264,7 @@ export const tag: GuildedCommand = { await reply({ embeds: [ new EmbedBuilder() - .setTitle('🗑️ Tag Removed') + .setTitle('Tag Removed') .setDescription(`Tag \`${name}\` has been deleted.`) .setColor(0xff_00_00), // Red ], diff --git a/src/helpers/tagUtils.test.ts b/src/helpers/tagUtils.test.ts index f1838422..e6a69b0b 100644 --- a/src/helpers/tagUtils.test.ts +++ b/src/helpers/tagUtils.test.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/unbound-method */ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { PrismaClient } from '@prisma/client'; import { createTag, getTag, @@ -8,41 +7,132 @@ import { incrementTagUseCount, deleteTag, searchTags, + validateImageAttachment, + downloadAttachment, } from './tagUtils.js'; -import { db } from '../database/index.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: new PrismaClient(), + 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 () => { - vi.spyOn(db.tag, 'findUnique').mockResolvedValue(null); - vi.spyOn(db.tag, 'create').mockResolvedValue({ - id: 1, - guildId: 'guild123', - name: 'test', - content: 'https://example.com/image.png', - createdBy: 'user123', - createdAt: new Date(), - useCount: 0, - }); + mockTagModel.findUnique.mockResolvedValue(null); + mockTagModel.create.mockResolvedValue(createMockTag()); const result = await createTag( 'guild123', 'test', - 'https://example.com/image.png', + fakeImageData, + 'image.png', + 'image/png', 'user123' ); expect(result).toBeDefined(); expect(result.name).toBe('test'); - expect(db.tag.findUnique).toHaveBeenCalledWith({ + expect(mockTagModel.findUnique).toHaveBeenCalledWith({ where: { guild_tag: { guildId: 'guild123', @@ -53,40 +143,33 @@ describe('tagUtils', () => { }); it('should throw error if tag already exists', async () => { - vi.spyOn(db.tag, 'findUnique').mockResolvedValue({ - id: 1, - guildId: 'guild123', - name: 'test', - content: 'https://example.com/image.png', - createdBy: 'user123', - createdAt: new Date(), - useCount: 0, - }); + mockTagModel.findUnique.mockResolvedValue(createMockTag()); await expect( - createTag('guild123', 'test', 'https://example.com/other.png', 'user456') + 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 () => { - vi.spyOn(db.tag, 'findUnique').mockResolvedValue(null); - vi.spyOn(db.tag, 'create').mockResolvedValue({ - id: 1, - guildId: 'guild123', - name: 'testname', - content: 'https://example.com/image.png', - createdBy: 'user123', - createdAt: new Date(), - useCount: 0, - }); + mockTagModel.findUnique.mockResolvedValue(null); + mockTagModel.create.mockResolvedValue(createMockTag({ name: 'testname' })); - await createTag('guild123', 'TestName', 'https://example.com/image.png', 'user123'); + await createTag( + 'guild123', + 'TestName', + fakeImageData, + 'image.png', + 'image/png', + 'user123' + ); - expect(db.tag.create).toHaveBeenCalledWith({ + expect(mockTagModel.create).toHaveBeenCalledWith({ data: { guildId: 'guild123', name: 'testname', - content: 'https://example.com/image.png', + imageData: fakeImageData, + fileName: 'image.png', + contentType: 'image/png', createdBy: 'user123', }, }); @@ -95,22 +178,13 @@ describe('tagUtils', () => { describe('getTag', () => { it('should return tag if it exists', async () => { - const mockTag = { - id: 1, - guildId: 'guild123', - name: 'test', - content: 'https://example.com/image.png', - createdBy: 'user123', - createdAt: new Date(), - useCount: 5, - }; - - vi.spyOn(db.tag, 'findUnique').mockResolvedValue(mockTag); + const tag = createMockTag({ useCount: 5 }); + mockTagModel.findUnique.mockResolvedValue(tag); const result = await getTag('guild123', 'test'); - expect(result).toEqual(mockTag); - expect(db.tag.findUnique).toHaveBeenCalledWith({ + expect(result).toEqual(tag); + expect(mockTagModel.findUnique).toHaveBeenCalledWith({ where: { guild_tag: { guildId: 'guild123', @@ -121,7 +195,7 @@ describe('tagUtils', () => { }); it('should return null if tag does not exist', async () => { - vi.spyOn(db.tag, 'findUnique').mockResolvedValue(null); + mockTagModel.findUnique.mockResolvedValue(null); const result = await getTag('guild123', 'nonexistent'); @@ -130,13 +204,14 @@ describe('tagUtils', () => { }); describe('getAllTags', () => { - it('should return all tags for a guild', async () => { - const mockTags = [ + it('should return all tags for a guild excluding imageData', async () => { + const tagsWithoutImageData = [ { id: 1, guildId: 'guild123', name: 'tag1', - content: 'content1', + fileName: 'img1.png', + contentType: 'image/png', createdBy: 'user1', createdAt: new Date(), useCount: 5, @@ -145,22 +220,33 @@ describe('tagUtils', () => { id: 2, guildId: 'guild123', name: 'tag2', - content: 'content2', + fileName: 'img2.gif', + contentType: 'image/gif', createdBy: 'user2', createdAt: new Date(), useCount: 10, }, ]; - vi.spyOn(db.tag, 'findMany').mockResolvedValue(mockTags); + mockTagModel.findMany.mockResolvedValue(tagsWithoutImageData); const result = await getAllTags('guild123'); - expect(result).toEqual(mockTags); - expect(db.tag.findMany).toHaveBeenCalledWith({ + 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', }, @@ -168,7 +254,7 @@ describe('tagUtils', () => { }); it('should return empty array if no tags exist', async () => { - vi.spyOn(db.tag, 'findMany').mockResolvedValue([]); + mockTagModel.findMany.mockResolvedValue([]); const result = await getAllTags('guild123'); @@ -178,19 +264,11 @@ describe('tagUtils', () => { describe('incrementTagUseCount', () => { it('should increment use count for tag', async () => { - vi.spyOn(db.tag, 'update').mockResolvedValue({ - id: 1, - guildId: 'guild123', - name: 'test', - content: 'content', - createdBy: 'user123', - createdAt: new Date(), - useCount: 6, - }); + mockTagModel.update.mockResolvedValue(createMockTag({ useCount: 6 })); await incrementTagUseCount('guild123', 'test'); - expect(db.tag.update).toHaveBeenCalledWith({ + expect(mockTagModel.update).toHaveBeenCalledWith({ where: { guild_tag: { guildId: 'guild123', @@ -208,23 +286,14 @@ describe('tagUtils', () => { describe('deleteTag', () => { it('should delete tag if user is creator', async () => { - const mockTag = { - id: 1, - guildId: 'guild123', - name: 'test', - content: 'content', - createdBy: 'user123', - createdAt: new Date(), - useCount: 0, - }; - - vi.spyOn(db.tag, 'findUnique').mockResolvedValue(mockTag); - vi.spyOn(db.tag, 'delete').mockResolvedValue(mockTag); + const tag = createMockTag(); + mockTagModel.findUnique.mockResolvedValue(tag); + mockTagModel.delete.mockResolvedValue(tag); const result = await deleteTag('guild123', 'test', 'user123'); expect(result).toBe(true); - expect(db.tag.delete).toHaveBeenCalledWith({ + expect(mockTagModel.delete).toHaveBeenCalledWith({ where: { guild_tag: { guildId: 'guild123', @@ -235,26 +304,16 @@ describe('tagUtils', () => { }); it('should return false if tag does not exist', async () => { - vi.spyOn(db.tag, 'findUnique').mockResolvedValue(null); + mockTagModel.findUnique.mockResolvedValue(null); const result = await deleteTag('guild123', 'test', 'user123'); expect(result).toBe(false); - expect(db.tag.delete).not.toHaveBeenCalled(); + expect(mockTagModel.delete).not.toHaveBeenCalled(); }); it('should throw error if user is not creator and not admin', async () => { - const mockTag = { - id: 1, - guildId: 'guild123', - name: 'test', - content: 'content', - createdBy: 'user123', - createdAt: new Date(), - useCount: 0, - }; - - vi.spyOn(db.tag, 'findUnique').mockResolvedValue(mockTag); + mockTagModel.findUnique.mockResolvedValue(createMockTag()); await expect(deleteTag('guild123', 'test', 'user456', false)).rejects.toThrow( 'You can only delete tags you created' @@ -262,23 +321,14 @@ describe('tagUtils', () => { }); it('should allow admin to delete any tag', async () => { - const mockTag = { - id: 1, - guildId: 'guild123', - name: 'test', - content: 'content', - createdBy: 'user123', - createdAt: new Date(), - useCount: 0, - }; - - vi.spyOn(db.tag, 'findUnique').mockResolvedValue(mockTag); - vi.spyOn(db.tag, 'delete').mockResolvedValue(mockTag); + const tag = createMockTag(); + mockTagModel.findUnique.mockResolvedValue(tag); + mockTagModel.delete.mockResolvedValue(tag); const result = await deleteTag('guild123', 'test', 'admin456', true); expect(result).toBe(true); - expect(db.tag.delete).toHaveBeenCalledWith({ + expect(mockTagModel.delete).toHaveBeenCalledWith({ where: { guild_tag: { guildId: 'guild123', @@ -293,13 +343,12 @@ describe('tagUtils', () => { it('should return matching tag names', async () => { const mockTags = [{ name: 'test1' }, { name: 'test2' }, { name: 'testing' }]; - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any - vi.spyOn(db.tag, 'findMany').mockResolvedValue(mockTags as any); + mockTagModel.findMany.mockResolvedValue(mockTags); const result = await searchTags('guild123', 'test'); expect(result).toEqual(['test1', 'test2', 'testing']); - expect(db.tag.findMany).toHaveBeenCalledWith({ + expect(mockTagModel.findMany).toHaveBeenCalledWith({ where: { guildId: 'guild123', name: { @@ -317,11 +366,11 @@ describe('tagUtils', () => { }); it('should respect limit parameter', async () => { - vi.spyOn(db.tag, 'findMany').mockResolvedValue([]); + mockTagModel.findMany.mockResolvedValue([]); await searchTags('guild123', 'test', 10); - expect(db.tag.findMany).toHaveBeenCalledWith( + expect(mockTagModel.findMany).toHaveBeenCalledWith( expect.objectContaining({ take: 10, }) diff --git a/src/helpers/tagUtils.ts b/src/helpers/tagUtils.ts index 049ec3be..dfb9d54b 100644 --- a/src/helpers/tagUtils.ts +++ b/src/helpers/tagUtils.ts @@ -1,11 +1,46 @@ 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 content Tag content (URL or text) + * @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 @@ -13,7 +48,9 @@ import type { Tag } from '@prisma/client'; export async function createTag( guildId: string, name: string, - content: string, + imageData: Buffer, + fileName: string, + contentType: string, createdBy: string ): Promise { // Check if tag already exists @@ -34,7 +71,9 @@ export async function createTag( data: { guildId, name: name.toLowerCase(), - content, + imageData, + fileName, + contentType, createdBy, }, }); @@ -57,16 +96,30 @@ export async function getTag(guildId: string, name: string): Promise }); } +// 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 + * @returns Array of tags (without imageData) */ -export async function getAllTags(guildId: string): Promise> { +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', },