diff --git a/CHANGELOG.md b/CHANGELOG.md index f94be10a..fb6b2b5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Node built-in `.env` file support - Automatic updates for dependencies with Dependabot - A read-only code mirror on [Codeberg](https://codeberg.org/BYU-CS-Discord/CSBot/) +- Starboard, and associated commands ### Changed diff --git a/README.md b/README.md index ac6cc3d6..0887e44c 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,10 @@ Retrieves the profile picture of the given user. 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. + ### /stats ( track / update / list / leaderboard / untrack ) 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. @@ -184,7 +188,7 @@ ADMINISTRATORS=COMMA,SEPARATED,ID,LIST ### Invite your bot to your server -Go to https://discordapi.com/permissions.html#378091424832 and paste in your bot's client ID to get an invite link. +Go to https://discordapi.com/permissions.html#379165174848 and paste in your bot's client ID to get an invite link. ### Setting up Docker diff --git a/package.json b/package.json index 005b8071..82a7456c 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "lint:fix": "npm run lint -- --fix", "release": "./node_modules/.bin/tsx ./scripts/release.ts", "restart": "./node_modules/.bin/pm2 restart cs-bot", - "setup": "npm ci && npm run export-version && npm run build --production && npm run commands:deploy", + "setup": "npm ci && npm run export-version && npm run db:migrate && npm run build --production && npm run commands:deploy", "start": "./node_modules/.bin/pm2 start ./dist/main.js --name cs-bot --node-args=\"--env-file=.env\"", "stop": "./node_modules/.bin/pm2 delete cs-bot", "test": "./node_modules/.bin/vitest", diff --git a/prisma/migrations/20230411151150_reactboard/migration.sql b/prisma/migrations/20230411151150_reactboard/migration.sql new file mode 100644 index 00000000..3cc9303d --- /dev/null +++ b/prisma/migrations/20230411151150_reactboard/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "reactboard" ( + "id" SERIAL NOT NULL, + "guildId" TEXT NOT NULL, + "channelId" TEXT NOT NULL, + "react" TEXT NOT NULL, + "isCustomReact" BOOLEAN NOT NULL, + "threshold" INTEGER NOT NULL, + + CONSTRAINT "reactboard_pkey" PRIMARY KEY ("id") +); diff --git a/prisma/migrations/20230411152017_reactboard_unique_location/migration.sql b/prisma/migrations/20230411152017_reactboard_unique_location/migration.sql new file mode 100644 index 00000000..074d39c7 --- /dev/null +++ b/prisma/migrations/20230411152017_reactboard_unique_location/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[guildId,channelId]` on the table `reactboard` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "reactboard_guildId_channelId_key" ON "reactboard"("guildId", "channelId"); diff --git a/prisma/migrations/20230411182041_reactboard_posts/migration.sql b/prisma/migrations/20230411182041_reactboard_posts/migration.sql new file mode 100644 index 00000000..c9e0e2bd --- /dev/null +++ b/prisma/migrations/20230411182041_reactboard_posts/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "reactboardPost" ( + "id" SERIAL NOT NULL, + "reactboardId" INTEGER NOT NULL, + "originalMessageId" TEXT NOT NULL, + "reactboardMessageId" TEXT NOT NULL, + + CONSTRAINT "reactboardPost_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "reactboardPost" ADD CONSTRAINT "reactboardPost_reactboardId_fkey" FOREIGN KEY ("reactboardId") REFERENCES "reactboard"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20230419170341_reactboard_upper_camel_case/migration.sql b/prisma/migrations/20230419170341_reactboard_upper_camel_case/migration.sql new file mode 100644 index 00000000..6a0fae87 --- /dev/null +++ b/prisma/migrations/20230419170341_reactboard_upper_camel_case/migration.sql @@ -0,0 +1,43 @@ +/* + Warnings: + + - You are about to drop the `reactboard` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `reactboardPost` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "reactboardPost" DROP CONSTRAINT "reactboardPost_reactboardId_fkey"; + +-- DropTable +DROP TABLE "reactboard"; + +-- DropTable +DROP TABLE "reactboardPost"; + +-- CreateTable +CREATE TABLE "Reactboard" ( + "id" SERIAL NOT NULL, + "guildId" TEXT NOT NULL, + "channelId" TEXT NOT NULL, + "react" TEXT NOT NULL, + "isCustomReact" BOOLEAN NOT NULL, + "threshold" INTEGER NOT NULL, + + CONSTRAINT "Reactboard_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ReactboardPost" ( + "id" SERIAL NOT NULL, + "reactboardId" INTEGER NOT NULL, + "originalMessageId" TEXT NOT NULL, + "reactboardMessageId" TEXT NOT NULL, + + CONSTRAINT "ReactboardPost_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Reactboard_guildId_channelId_key" ON "Reactboard"("guildId", "channelId"); + +-- AddForeignKey +ALTER TABLE "ReactboardPost" ADD CONSTRAINT "ReactboardPost_reactboardId_fkey" FOREIGN KEY ("reactboardId") REFERENCES "Reactboard"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20230419171816_reactboard_original_channel_id/migration.sql b/prisma/migrations/20230419171816_reactboard_original_channel_id/migration.sql new file mode 100644 index 00000000..e80249a5 --- /dev/null +++ b/prisma/migrations/20230419171816_reactboard_original_channel_id/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `originalChannelId` to the `ReactboardPost` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "ReactboardPost" ADD COLUMN "originalChannelId" TEXT NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 48854663..2e22bb2a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -10,6 +10,28 @@ datasource db { url = env("DATABASE_URL") } +model Reactboard { + id Int @id @default(autoincrement()) + guildId String + channelId String + react String + isCustomReact Boolean + threshold Int + + @@unique([guildId, channelId], name: "location") + + reactboardPosts ReactboardPost[] +} + +model ReactboardPost { + id Int @id @default(autoincrement()) + reactboardId Int + reactboard Reactboard @relation(fields: [reactboardId], references: [id]) + originalMessageId String + originalChannelId String + reactboardMessageId String +} + model Scoreboard { id Int @id @default(autoincrement()) userId String @@ -18,23 +40,6 @@ model Scoreboard { score Float } -// All our Models will be defined here -// Below is an example of what one would look like in our usecase -// NOTE NOTHING HERE IS FINAL, THIS IS JUST AN EXAMPLE -// Idk what I am doing - -// model Emoteboard { -// id Int @id @default(autoincrement()) -// createdAt DateTime @default(now()) -// author String -// postId String @unique -// count Int -// emote Emote @default(STAR) -// channel String -// guild String -// userId String -// } - // model Tag { // id Int @id @default(autoincrement()) // createdAt DateTime @default(now()) @@ -49,16 +54,3 @@ model Scoreboard { // starredCount Int // leaderboards Leaderboard[] // } - -// model Leaderboard { -// id Int @id @default(autoincrement()) -// createdAt DateTime @default(now()) -// name String -// users User[] -// userCounts Int[] -// } - -// enum Emote { -// STAR -// BASED -// } diff --git a/src/@types/ReactionHandler.d.ts b/src/@types/ReactionHandler.d.ts new file mode 100644 index 00000000..f8e9dcde --- /dev/null +++ b/src/@types/ReactionHandler.d.ts @@ -0,0 +1,12 @@ +import type { MessageReaction, PartialMessageReaction, PartialUser, User } from 'discord.js'; + +declare global { + interface ReactionHandler { + execute: (context: ReactionHandlerContext) => void | Promise; + } + + interface ReactionHandlerContext { + reaction: MessageReaction | PartialMessageReaction; + user: User | PartialUser; + } +} diff --git a/src/commands/setReactboard.test.ts b/src/commands/setReactboard.test.ts new file mode 100644 index 00000000..566c499f --- /dev/null +++ b/src/commands/setReactboard.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { DeepMockProxy } from 'vitest-mock-extended'; +import { mockDeep } from 'vitest-mock-extended'; + +import type { PrismaClient } from '@prisma/client'; +import type { TextChannel } from 'discord.js'; + +import { setReactboard } from './setReactboard.js'; +import { db } from '../database/index.js'; +import { UserMessageError } from '../helpers/UserMessageError.js'; + +vi.mock('../database', () => ({ + db: mockDeep(), +})); + +describe('setReactboard', () => { + const dbMock = db as unknown as DeepMockProxy; + // eslint-disable-next-line @typescript-eslint/unbound-method + const mockUpsert = dbMock.reactboard.upsert; + + const mockGuildId = 'test-guild-id'; + const mockChannel = { + id: 'test-channel-id', + }; + const mockThreshold = 5; + const mockReact = '⭐'; + + const mockReplyPrivately = vi.fn(); + const mockGetString = vi.fn(); + const mockGetInteger = vi.fn(); + const mockGetChannel = vi.fn(); + const mockGetEmoji = vi.fn(); + let context: GuildedCommandContext; + + beforeEach(() => { + context = { + replyPrivately: mockReplyPrivately, + options: { + getString: mockGetString, + getInteger: mockGetInteger, + getChannel: mockGetChannel, + }, + guild: { + id: mockGuildId, + emojis: { + fetch: mockGetEmoji, + }, + }, + } as unknown as GuildedCommandContext; + + mockGetChannel.mockReturnValue(mockChannel as unknown as TextChannel); + mockGetInteger.mockReturnValue(mockThreshold); + mockGetString.mockReturnValue(mockReact); + + vi.clearAllMocks(); + }); + + test('upserts a reactboard', async () => { + await setReactboard.execute(context); + expect(mockUpsert).toHaveBeenCalledTimes(1); + expect(mockUpsert).toHaveBeenCalledWith({ + where: { + location: { + channelId: mockChannel.id, + guildId: mockGuildId, + }, + }, + update: { + threshold: mockThreshold, + react: mockReact, + isCustomReact: false, + }, + create: { + channelId: mockChannel.id, + guildId: mockGuildId, + threshold: mockThreshold, + react: mockReact, + isCustomReact: false, + }, + }); + expect(mockReplyPrivately).toHaveBeenCalledTimes(1); + }); + + test('upserts a reactboard with a custom emoji', async () => { + const customEmojiId = '1234567890'; + mockGetString.mockReturnValue(`<:abcdef:${customEmojiId}>`); + mockGetEmoji.mockReturnValue({ + id: customEmojiId, + }); + + await setReactboard.execute(context); + expect(mockUpsert).toHaveBeenCalledTimes(1); + expect(mockUpsert).toHaveBeenCalledWith({ + where: { + location: { + channelId: mockChannel.id, + guildId: mockGuildId, + }, + }, + update: { + threshold: mockThreshold, + react: customEmojiId, + isCustomReact: true, + }, + create: { + channelId: mockChannel.id, + guildId: mockGuildId, + threshold: mockThreshold, + react: customEmojiId, + isCustomReact: true, + }, + }); + expect(mockReplyPrivately).toHaveBeenCalledTimes(1); + }); + + test('fails with UserMessageError when threshold is below 1', async () => { + mockGetInteger.mockReturnValue(0); + + await expect(setReactboard.execute(context)).rejects.toThrow(UserMessageError); + }); +}); diff --git a/src/commands/setReactboard.ts b/src/commands/setReactboard.ts new file mode 100644 index 00000000..9ef1ca63 --- /dev/null +++ b/src/commands/setReactboard.ts @@ -0,0 +1,100 @@ +import { Guild, GuildEmoji, SlashCommandBuilder } from 'discord.js'; + +import { UserMessageError } from '../helpers/UserMessageError.js'; +import { db } from '../database/index.js'; + +const channelOption = 'channel'; +const thresholdOption = 'threshold'; +const reactOption = 'react'; + +const builder = new SlashCommandBuilder() + .setDefaultMemberPermissions('0') + .setName('setreactboard') + .setDescription('Creates or modifies reaction board in this server') + .addChannelOption(option => + option + .setName(channelOption) + .setDescription('The channel where reactboard posts will be posted') + .setRequired(true) + ) + .addIntegerOption(option => + option + .setName(thresholdOption) + .setDescription( + 'The minimum number of reacts a message should receive before being put on the board' + ) + .setMinValue(1) + .setRequired(true) + ) + .addStringOption(option => + option.setName(reactOption).setDescription('The react to be tracked (defaults to ⭐)') + ); + +interface ReactboardReactInfo { + react: string; + isCustomReact: boolean; +} + +export const setReactboard: GuildedCommand = { + info: builder, + requiresGuild: true, + async execute({ guild, options, replyPrivately }) { + const channel = options.getChannel(channelOption, true); + const threshold = options.getInteger(thresholdOption, true); + const react = options.getString(reactOption) ?? '⭐'; + + if (threshold < 1) { + throw new UserMessageError('Threshold must be at least one'); + } + + let reactboardInfo: ReactboardReactInfo; + if (isUnicodeEmoji(react)) { + reactboardInfo = { + react, + isCustomReact: false, + }; + } else { + const customReact = await getCustomReact(guild, react); + if (customReact === undefined) { + throw new UserMessageError('React option must be a valid reaction'); + } + reactboardInfo = { + react: customReact.id, + isCustomReact: true, + }; + } + + await db.reactboard.upsert({ + where: { + location: { + channelId: channel.id, + guildId: guild.id, + }, + }, + update: { + threshold, + ...reactboardInfo, + }, + create: { + channelId: channel.id, + guildId: guild.id, + threshold, + ...reactboardInfo, + }, + }); + + await replyPrivately('Reactboard created!'); + }, +}; + +function isUnicodeEmoji(str: string): boolean { + return Boolean(/^\p{Extended_Pictographic}$/u.test(str)); +} + +async function getCustomReact(guild: Guild, str: string): Promise { + const reactId = str.match(/^$/u)?.[1]; + if (reactId === undefined) { + return undefined; + } + return guild.emojis.fetch(reactId); +} diff --git a/src/events/index.ts b/src/events/index.ts index 1de8b5c7..59b97a4a 100644 --- a/src/events/index.ts +++ b/src/events/index.ts @@ -56,10 +56,12 @@ export function registerEventHandlers(client: Client): void { import { error } from './error.js'; import { interactionCreate } from './interactionCreate.js'; import { messageReactionAdd } from './messageReactionAdd.js'; +import { messageReactionRemove } from './messageReactionRemove.js'; import { ready } from './ready.js'; _add(error as EventHandler); _add(interactionCreate as EventHandler); _add(messageReactionAdd as EventHandler); +_add(messageReactionRemove as EventHandler); _add(ready as EventHandler); // Not sure why these type casts are necessary, but they seem sound. We can remove them when TS gets smarter, or we learn what I did wrong diff --git a/src/events/messageReaction.test.ts b/src/events/messageReaction.test.ts new file mode 100644 index 00000000..178eaa0c --- /dev/null +++ b/src/events/messageReaction.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { MessageReaction, User } from 'discord.js'; + +import { buildExecute } from './messageReaction.js'; + +const mockHandlerExecute = vi.fn(); +const mockReactionHandler = { + execute: mockHandlerExecute, +}; +const testExecute = buildExecute(new Set([mockReactionHandler])); + +describe('Reaction duplication', () => { + let mockReaction: MessageReaction; + let mockSender: User; + + beforeEach(() => { + mockReaction = { + me: false, + client: { + user: { + id: 'itz-meeee', + }, + }, + message: { + author: { + id: 'other-user', + }, + }, + emoji: { + name: 'blue_square', + }, + count: 1, + } as unknown as MessageReaction; + + mockSender = { + bot: false, + } as unknown as User; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('ignores emoji with an empty name', async () => { + mockReaction.emoji.name = ''; + await testExecute(mockReaction, mockSender); + expect(mockHandlerExecute).not.toHaveBeenCalled(); + }); + + test('ignores emoji with a null name', async () => { + mockReaction.emoji.name = null; + await testExecute(mockReaction, mockSender); + expect(mockHandlerExecute).not.toHaveBeenCalled(); + }); + + test('ignores bot reacts', async () => { + mockSender.bot = true; + await testExecute(mockReaction, mockSender); + expect(mockHandlerExecute).not.toHaveBeenCalled(); + }); + + test("ignores the bot's own reacts", async () => { + mockReaction.me = true; + await testExecute(mockReaction, mockSender); + expect(mockHandlerExecute).not.toHaveBeenCalled(); + }); +}); diff --git a/src/events/messageReaction.ts b/src/events/messageReaction.ts new file mode 100644 index 00000000..0ef7eeba --- /dev/null +++ b/src/events/messageReaction.ts @@ -0,0 +1,27 @@ +import type { MessageReaction, PartialMessageReaction, PartialUser, User } from 'discord.js'; + +export function buildExecute(allReactionHandlers: ReadonlySet) { + return async function execute( + reaction: MessageReaction | PartialMessageReaction, + user: User | PartialUser + ): Promise { + // Ignore nameless emoji. Not sure what those are about + if (!reaction.emoji.name) return; + + if ( + user.bot || // Ignore bots + reaction.me || // Ignore own reacts + reaction.message.author?.id === reaction.client.user.id // Never self-react + ) { + return; + } + + const handlerPromises = [...allReactionHandlers].map(async handler => { + await handler.execute({ + reaction, + user, + }); + }); + await Promise.all(handlerPromises); + }; +} diff --git a/src/events/messageReactionAdd.ts b/src/events/messageReactionAdd.ts index 5d85fe5a..fd94c90e 100644 --- a/src/events/messageReactionAdd.ts +++ b/src/events/messageReactionAdd.ts @@ -1,43 +1,11 @@ -import { chances, DEFAULT_CHANCE } from '../constants/reactionDuplication.js'; import { onEvent } from '../helpers/onEvent.js'; -import { debug } from '../logger.js'; +import { allReactionHandlers } from '../reactionHandlers/add.js'; +import { buildExecute } from './messageReaction.js'; /** * The event handler for emoji reactions. */ export const messageReactionAdd = onEvent('messageReactionAdd', { once: false, - async execute(reaction, user) { - const ogEmojiName = reaction.emoji.name ?? ''; - const emojiName = ogEmojiName.toLowerCase(); - - // Ignore nameless emoji. Not sure what those are about - if (!emojiName) return; - - if ( - user.bot || // ignore bots - reaction.me || // ignore own reacts - reaction.message.author?.id === reaction.client.user.id || // never self-react - (reaction.count ?? 0) > 1 // never join the bandwagon - ) { - return; - } - - // The chances, where 1 is always, 100 is once every 100 times, and 0 is never - const chance = chances[emojiName] ?? DEFAULT_CHANCE; - - if (chance === 0) { - debug(`There is no chance I'd react to :${ogEmojiName}:`); - return; - } - - debug(`There is a 1-in-${chance} chance that I'd react to :${ogEmojiName}:`); - const random = Math.round(Math.random() * 100); - if (random % chance === 0) { - debug('I did.'); - await reaction.react(); - } else { - debug('I did not.'); - } - }, + execute: buildExecute(allReactionHandlers), }); diff --git a/src/events/messageReactionRemove.ts b/src/events/messageReactionRemove.ts new file mode 100644 index 00000000..7cc9d8a2 --- /dev/null +++ b/src/events/messageReactionRemove.ts @@ -0,0 +1,11 @@ +import { onEvent } from '../helpers/onEvent.js'; +import { allReactionHandlers } from '../reactionHandlers/remove.js'; +import { buildExecute } from './messageReaction.js'; + +/** + * The event handler for emoji reactions. + */ +export const messageReactionRemove = onEvent('messageReactionRemove', { + once: false, + execute: buildExecute(allReactionHandlers), +}); diff --git a/src/main.ts b/src/main.ts index 8d0d8509..6951f071 100644 --- a/src/main.ts +++ b/src/main.ts @@ -20,6 +20,7 @@ export async function _main(): Promise { GatewayIntentBits.DirectMessages, GatewayIntentBits.GuildMessageTyping, GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.MessageContent, ], partials: [Partials.Reaction, Partials.Channel, Partials.Message], allowedMentions: { diff --git a/src/reactionHandlers/add.test.ts b/src/reactionHandlers/add.test.ts new file mode 100644 index 00000000..c8d3b23c --- /dev/null +++ b/src/reactionHandlers/add.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from 'vitest'; + +import { _add, allReactionHandlers } from './add.js'; + +describe('allReactionHandlers', () => { + test('index is not empty', () => { + expect(allReactionHandlers.size).toBeGreaterThan(0); + }); + + test('commands can be added', () => { + expect(() => { + _add({ execute: () => void 0 }); + }).not.toThrow(TypeError); + }); +}); diff --git a/src/reactionHandlers/add.ts b/src/reactionHandlers/add.ts new file mode 100644 index 00000000..730271ad --- /dev/null +++ b/src/reactionHandlers/add.ts @@ -0,0 +1,32 @@ +/** + * The private list of all reaction handlers. You can use this to edit the list within this file. + * @private + */ +const _allHandlers = new Set(); + +/** + * A read-only list of all reaction handlers. + * @public + */ +export const allReactionHandlers: ReadonlySet = _allHandlers; + +/** + * Adds a handler to the list of all reaction handlers. + * Only public for testing purposes. Do not use outside this file or its tests. + * @param handler The handler to add + * @private + */ +export function _add(handler: ReactionHandler): void { + if (_allHandlers.has(handler)) { + throw new TypeError('Failed to add handler that was already registered'); + } + + _allHandlers.add(handler); +} + +/** Install handlers here: **/ +import { duplicate } from './duplicate.js'; +import { updateReactboard } from './updateReactboard.js'; + +_add(duplicate); +_add(updateReactboard); diff --git a/src/events/messageReactionAdd.test.ts b/src/reactionHandlers/duplicate.test.ts similarity index 55% rename from src/events/messageReactionAdd.test.ts rename to src/reactionHandlers/duplicate.test.ts index 2ad61c84..0b950ee7 100644 --- a/src/events/messageReactionAdd.test.ts +++ b/src/reactionHandlers/duplicate.test.ts @@ -3,8 +3,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import type { MessageReaction, User } from 'discord.js'; -import { messageReactionAdd } from './messageReactionAdd.js'; +import { duplicate } from './duplicate.js'; +// Mock the logger so nothing is printed vi.mock('../logger.js'); describe('Reaction duplication', () => { @@ -13,6 +14,7 @@ describe('Reaction duplication', () => { let mockRandom: MockInstance; let mockReaction: MessageReaction; let mockSender: User; + let mockContext: ReactionHandlerContext; beforeEach(() => { mockRandom = vi.spyOn(global.Math, 'random').mockReturnValue(1); @@ -39,6 +41,11 @@ describe('Reaction duplication', () => { mockSender = { bot: false, } as unknown as User; + + mockContext = { + reaction: mockReaction, + user: mockSender, + }; }); afterEach(() => { @@ -46,43 +53,19 @@ describe('Reaction duplication', () => { }); test("sometimes duplicates a user's react", async () => { - await messageReactionAdd.execute(mockReaction, mockSender); + await duplicate.execute(mockContext); expect(mockResendReact).toHaveBeenCalledOnce(); }); test("sometimes ignores a user's react", async () => { mockRandom.mockReturnValue(0.5); - await messageReactionAdd.execute(mockReaction, mockSender); - expect(mockResendReact).not.toHaveBeenCalled(); - }); - - test('ignores emoji with an empty name', async () => { - mockReaction.emoji.name = ''; - await messageReactionAdd.execute(mockReaction, mockSender); - expect(mockResendReact).not.toHaveBeenCalled(); - }); - - test('ignores emoji with a null name', async () => { - mockReaction.emoji.name = null; - await messageReactionAdd.execute(mockReaction, mockSender); - expect(mockResendReact).not.toHaveBeenCalled(); - }); - - test('ignores bot reacts', async () => { - mockSender.bot = true; - await messageReactionAdd.execute(mockReaction, mockSender); - expect(mockResendReact).not.toHaveBeenCalled(); - }); - - test("ignores the bot's own reacts", async () => { - mockReaction.me = true; - await messageReactionAdd.execute(mockReaction, mockSender); + await duplicate.execute(mockContext); expect(mockResendReact).not.toHaveBeenCalled(); }); test('ignores :star:', async () => { mockReaction.emoji.name = '⭐'; - await messageReactionAdd.execute(mockReaction, mockSender); + await duplicate.execute(mockContext); expect(mockResendReact).not.toHaveBeenCalled(); }); }); diff --git a/src/reactionHandlers/duplicate.ts b/src/reactionHandlers/duplicate.ts new file mode 100644 index 00000000..97f2cb6c --- /dev/null +++ b/src/reactionHandlers/duplicate.ts @@ -0,0 +1,31 @@ +import { chances, DEFAULT_CHANCE } from '../constants/reactionDuplication.js'; +import { debug } from '../logger.js'; + +export const duplicate: ReactionHandler = { + async execute({ reaction }) { + const ogEmojiName = reaction.emoji.name ?? ''; + const emojiName = ogEmojiName.toLowerCase(); + + // Never join the bandwagon + if ((reaction.count ?? 0) > 1) { + return; + } + + // The chances, where 1 is always, 100 is once every 100 times, and 0 is never + const chance = chances[emojiName] ?? DEFAULT_CHANCE; + + if (chance === 0) { + debug(`There is no chance I'd react to :${ogEmojiName}:`); + return; + } + + debug(`There is a 1-in-${chance} chance that I'd react to :${ogEmojiName}:`); + const random = Math.round(Math.random() * 100); + if (random % chance === 0) { + debug('I did.'); + await reaction.react(); + } else { + debug('I did not.'); + } + }, +}; diff --git a/src/reactionHandlers/remove.test.ts b/src/reactionHandlers/remove.test.ts new file mode 100644 index 00000000..8917e455 --- /dev/null +++ b/src/reactionHandlers/remove.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from 'vitest'; + +import { _add, allReactionHandlers } from './remove.js'; + +describe('allReactionHandlers', () => { + test('index is not empty', () => { + expect(allReactionHandlers.size).toBeGreaterThan(0); + }); + + test('commands can be added', () => { + expect(() => { + _add({ execute: () => void 0 }); + }).not.toThrow(TypeError); + }); +}); diff --git a/src/reactionHandlers/remove.ts b/src/reactionHandlers/remove.ts new file mode 100644 index 00000000..bb160658 --- /dev/null +++ b/src/reactionHandlers/remove.ts @@ -0,0 +1,30 @@ +/** + * The private list of all reaction handlers. You can use this to edit the list within this file. + * @private + */ +const _allHandlers = new Set(); + +/** + * A read-only list of all reaction handlers. + * @public + */ +export const allReactionHandlers: ReadonlySet = _allHandlers; + +/** + * Adds a handler to the list of all reaction handlers. + * Only public for testing purposes. Do not use outside this file or its tests. + * @param handler The handler to add + * @private + */ +export function _add(handler: ReactionHandler): void { + if (_allHandlers.has(handler)) { + throw new TypeError('Failed to add handler that was already registered'); + } + + _allHandlers.add(handler); +} + +/** Install handlers here: **/ +import { updateReactboard } from './updateReactboard.js'; + +_add(updateReactboard); diff --git a/src/reactionHandlers/updateReactboard.test.ts b/src/reactionHandlers/updateReactboard.test.ts new file mode 100644 index 00000000..dbd5707e --- /dev/null +++ b/src/reactionHandlers/updateReactboard.test.ts @@ -0,0 +1,225 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { mockDeep } from 'vitest-mock-extended'; +import type { DeepMockProxy } from 'vitest-mock-extended'; + +import type { PrismaClient, Reactboard, ReactboardPost } from '@prisma/client'; +import { ChannelType, Client, Message, TextChannel, User } from 'discord.js'; + +import { db } from '../database/index.js'; +import { updateReactboard } from './updateReactboard.js'; + +vi.mock('../database/index.js', () => ({ + db: mockDeep(), +})); + +describe('updateReactboard', () => { + const dbMock = db as unknown as DeepMockProxy; + /* eslint-disable @typescript-eslint/unbound-method */ + const mockReactboardCount = dbMock.reactboard.count; + const mockReactboardPostFindMany = dbMock.reactboardPost.findMany; + const mockReactboardFindMany = dbMock.reactboard.findMany; + const mockReactboardPostCreate = dbMock.reactboardPost.create; + /* eslint-enable @typescript-eslint/unbound-method */ + + const mockGuildId = 'test-guild-id'; + const mockMessageId = 'test-message-id'; + const mockReactboardPostId = 'test-reactboard-post-id'; + const mockUsername = 'test-username'; + const mockChannelId = 'test-channel-id'; + const mockReactboardChannelId = 'test-reactboard-channel-id'; + const mockMessageAuthorId = 'test-message-author-id'; + const mockReactorId = 'test-reactor-id'; + const mockReactboardId = 0; + const mockReact = '⭐'; + + const mockReactionFetch = vi.fn(); + const mockMessageFetch = vi.fn(); + const mockUserFetch = vi.fn(); + const mockChannelFetch = vi.fn(); + const mockMessageFetchById = vi.fn(); + const mockSend = vi.fn(); + const mockEdit = vi.fn(); + const mockChannelIsTextBased = vi.fn(); + const mockRemoveReact = vi.fn(); + const mockAvatarUrl = vi.fn(); + let context: ReactionHandlerContext; + + const baseAuthor = { + id: mockMessageAuthorId, + bot: false, + username: mockUsername, + displayAvatarURL: mockAvatarUrl, + }; + const baseFullMessage = { + id: mockMessageId, + guildId: mockGuildId, + author: baseAuthor, + channel: { + id: mockChannelId, + send: mockSend, + }, + cleanContent: 'something funny', + attachments: { + first: (): { contentType: string; url: string } => ({ + contentType: 'image/jpeg', + url: 'http://test.com/image', + }), + }, + }; + + beforeEach(() => { + context = { + reaction: { + fetch: mockReactionFetch, + message: { + fetch: mockMessageFetch, + partial: true, + }, + users: { + remove: mockRemoveReact, + }, + partial: true, + }, + user: { + id: mockReactorId, + partial: true, + fetch: mockUserFetch, + }, + } as unknown as ReactionHandlerContext; + + mockReactionFetch.mockResolvedValue({ + emoji: { + name: mockReact, + }, + message: { + id: mockMessageId, + guildId: mockGuildId, + }, + client: { + channels: { + fetch: mockChannelFetch, + }, + }, + count: 5, + }); + + mockMessageFetch.mockResolvedValue(baseFullMessage); + + mockReactboardCount.mockResolvedValue(1); + + mockReactboardPostFindMany.mockResolvedValue([]); + + mockReactboardFindMany.mockResolvedValue([]); + + mockChannelFetch.mockResolvedValue({ + isTextBased: mockChannelIsTextBased, + type: ChannelType.GuildText, + messages: { + fetch: mockMessageFetchById, + }, + send: mockSend, + }); + + mockChannelIsTextBased.mockReturnValue(true); + + mockMessageFetchById.mockResolvedValue({ + edit: mockEdit, + }); + + mockSend.mockResolvedValue({ + id: mockReactboardPostId, + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + test('does nothing if the react isnt in a guild', async () => { + mockMessageFetch.mockResolvedValue({ + ...baseFullMessage, + guildId: null, + }); + + await updateReactboard.execute(context); + expect(mockReactboardPostCreate).not.toHaveBeenCalled(); + expect(mockSend).not.toHaveBeenCalled(); + expect(mockEdit).not.toHaveBeenCalled(); + expect(mockRemoveReact).not.toHaveBeenCalled(); + }); + + test('does nothing if the react isnt part of a reactboard', async () => { + mockReactboardCount.mockResolvedValue(0); + + await updateReactboard.execute(context); + expect(mockReactboardPostCreate).not.toHaveBeenCalled(); + expect(mockSend).not.toHaveBeenCalled(); + expect(mockEdit).not.toHaveBeenCalled(); + expect(mockRemoveReact).not.toHaveBeenCalled(); + }); + + test('prevents the user from reactboard reacting to a bot message', async () => { + mockMessageFetch.mockResolvedValue({ + ...baseFullMessage, + author: { + ...baseAuthor, + bot: true, + }, + }); + + await updateReactboard.execute(context); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockRemoveReact).toHaveBeenCalledTimes(1); + }); + + test('prevents the user from reactboard reacting to their own message', async () => { + mockMessageFetch.mockResolvedValue({ + ...baseFullMessage, + author: { + ...baseAuthor, + id: mockReactorId, + }, + }); + + await updateReactboard.execute(context); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockRemoveReact).toHaveBeenCalledTimes(1); + }); + + test('updates existing posts if there are any', async () => { + mockReactboardPostFindMany.mockResolvedValue([ + { + reactboard: { + channelId: mockReactboardChannelId, + }, + reactboardPost: mockReactboardPostId, + } as unknown as ReactboardPost, + ]); + + await updateReactboard.execute(context); + expect(mockEdit).toHaveBeenCalledTimes(1); + expect(mockSend).not.toHaveBeenCalled(); + expect(mockReactboardPostCreate).not.toHaveBeenCalled(); + }); + + test("creates new reactboard post if one doesn't already exist", async () => { + mockReactboardFindMany.mockResolvedValue([ + { + channelId: mockReactboardChannelId, + id: mockReactboardId, + } as unknown as Reactboard, + ]); + + await updateReactboard.execute(context); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockReactboardPostCreate).toHaveBeenCalledTimes(1); + expect(mockReactboardPostCreate).toHaveBeenCalledWith({ + data: { + reactboardId: mockReactboardId, + originalMessageId: mockMessageId, + reactboardMessageId: mockReactboardPostId, + }, + }); + expect(mockEdit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/reactionHandlers/updateReactboard.ts b/src/reactionHandlers/updateReactboard.ts new file mode 100644 index 00000000..2ce192f8 --- /dev/null +++ b/src/reactionHandlers/updateReactboard.ts @@ -0,0 +1,194 @@ +import type { PartialDMChannel, PrivateThreadChannel, PublicThreadChannel } from 'discord.js'; +import { + Attachment, + channelMention, + ChannelType, + DMChannel, + EmbedBuilder, + Message, + MessageReaction, + NewsChannel, + TextChannel, + userMention, + VoiceChannel, +} from 'discord.js'; + +import { db } from '../database/index.js'; +import { appVersion } from '../constants/meta.js'; + +export const updateReactboard: ReactionHandler = { + async execute({ reaction, user }) { + const fullReaction = reaction.partial ? await reaction.fetch() : reaction; + const fullMessage = reaction.message.partial + ? await reaction.message.fetch() + : reaction.message; + const fullUser = user.partial ? await user.fetch() : user; + if (fullMessage.guildId === null) return; // Ignore guildless messages + + const reactboardExists = + (await db.reactboard.count({ + where: { + guildId: fullMessage.guildId, + react: getDbReactName(fullReaction), + }, + })) > 0; + if (!reactboardExists) return; // Abort if no reactboard + + if (fullMessage.author.bot) { + await fullMessage.channel.send( + `${userMention(user.id)}, you can't use that react on bot messages!` + ); + await reaction.users.remove(fullUser); + return; + } + + if (fullMessage.author.id === user.id) { + await fullMessage.channel.send( + `${userMention(user.id)}, you can't use that react on your own messages!` + ); + await reaction.users.remove(fullUser); + return; + } + + await updateExistingPosts(fullReaction, fullMessage); + await addNewPosts(fullReaction, fullMessage); + }, +}; + +async function updateExistingPosts(reaction: MessageReaction, message: Message): Promise { + const reactboardPosts = await db.reactboardPost.findMany({ + where: { + originalMessageId: reaction.message.id, + originalChannelId: reaction.message.channelId, + reactboard: { + react: getDbReactName(reaction), + }, + }, + include: { + reactboard: true, + }, + }); + + const updatePromises = reactboardPosts.map(async reactboardPost => { + const reactboardChannel = await getChannel(reaction, reactboardPost.reactboard.channelId); + const reactboardMessage = await reactboardChannel.messages.fetch( + reactboardPost.reactboardMessageId + ); + await reactboardMessage.edit({ embeds: [buildEmbed(reaction, message)] }); + }); + + await Promise.all(updatePromises); +} + +async function addNewPosts(reaction: MessageReaction, message: Message): Promise { + if (reaction.message.guildId === null) return; // Ignore guildless messages + + const reactboardsToPostTo = await db.reactboard.findMany({ + where: { + guildId: reaction.message.guildId, + react: getDbReactName(reaction), + threshold: { + lte: reaction.count, + }, + reactboardPosts: { + none: { + originalMessageId: reaction.message.id, + originalChannelId: reaction.message.channelId, + }, + }, + }, + }); + + const updatePromises = reactboardsToPostTo.map(async reactboard => { + const channel = await getChannel(reaction, reactboard.channelId); + const reactboardMessage = await channel.send({ embeds: [buildEmbed(reaction, message)] }); + await db.reactboardPost.create({ + data: { + reactboardId: reactboard.id, + originalMessageId: reaction.message.id, + originalChannelId: reaction.message.channelId, + reactboardMessageId: reactboardMessage.id, + }, + }); + }); + + await Promise.all(updatePromises); +} + +async function getChannel( + reaction: MessageReaction, + channelId: string +): Promise< + | DMChannel + | PartialDMChannel + | NewsChannel + | TextChannel + | PrivateThreadChannel + | PublicThreadChannel + | VoiceChannel +> { + const channel = await reaction.client.channels.fetch(channelId); + if (channel === null || !channel.isTextBased() || channel.type === ChannelType.GuildStageVoice) { + throw new Error('Could not find channel'); + } + return channel; +} + +function getDbReactName(reaction: MessageReaction): string { + const name = reaction.emoji.id ?? reaction.emoji.name; + if (name === null) { + throw new Error('Could not identify react emoji'); + } + + return name; +} + +function buildEmbed(reaction: MessageReaction, message: Message): EmbedBuilder { + const name = message.author.username; + const avatarUrl = message.author.displayAvatarURL(); + const content = message.cleanContent; + const image = extractImageUrl(message.attachments.first()); + const emoji = reaction.emoji.toString(); + const guildId = message.guildId; + + if (guildId === null) { + throw new Error('Reactboard embed must be in guild'); + } + + const embed = new EmbedBuilder() + .setAuthor({ name, iconURL: avatarUrl }) + .setFooter({ text: `v${appVersion}` }) + .setColor('Gold') + .setTimestamp(new Date()) + .setImage(image) + .addFields([ + { + name: `${emoji} Reacts`, + value: reaction.count.toString(), + inline: true, + }, + { + name: 'Channel', + value: channelMention(message.channel.id), + inline: true, + }, + { + name: ':arrow_heading_up: Jump', + value: `[Click Me](https://discordapp.com/channels/${guildId}/${message.channel.id}/${message.id})`, + inline: true, + }, + ]); + + if (content.length > 0) { + embed.setDescription(content); + } + + return embed; +} + +function extractImageUrl(attachment: Attachment | undefined): string | null { + if (!attachment?.contentType?.includes('image')) { + return null; + } + return attachment.url; +}