Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

13 starboard #96

Open
wants to merge 49 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
a5cbad9
set up setReactboard command
Plyb Apr 11, 2023
7bb0c8b
add code for discriminating reaction types
Plyb Apr 11, 2023
c1e2636
save reactboard settings in db
Plyb Apr 11, 2023
c5958cf
factor out reaction duplication code
Plyb Apr 11, 2023
a1aaf23
add updateReactboard handler with reactboard fetching
Plyb Apr 11, 2023
6a17ecd
partial reactboard message finding
Plyb Apr 13, 2023
9f5aaf1
Merge branch 'main' into 13-starboard
Plyb Apr 13, 2023
46e04bc
add updating of existing posts
Plyb Apr 13, 2023
408288c
add posting of new messages
Plyb Apr 13, 2023
bf1f0c3
factor out channel fetching
Plyb Apr 13, 2023
4e37de0
factor out adding of new posts
Plyb Apr 13, 2023
0bc8307
save posts in db so we don't get duplicate posts
Plyb Apr 13, 2023
6885661
enable built in emoji support
Plyb Apr 14, 2023
dd12c17
add ability to detect removals
Plyb Apr 14, 2023
442aed8
build embed
Plyb Apr 14, 2023
776de18
prevent illegal starrings
Plyb Apr 14, 2023
cb423d8
add tests for setReactboard command
Plyb Apr 14, 2023
42ac27a
add tests for updating reactboard
Plyb Apr 15, 2023
2a4d926
update documentation
Plyb Apr 15, 2023
da728e0
restrict setreactboard to admins
Plyb Apr 15, 2023
fd667c7
Merge branch '13-starboard' of https://github.com/BYU-CS-Discord/CSBo…
Plyb Apr 15, 2023
a4740f9
Merge branch 'main' into 13-starboard
Plyb Apr 15, 2023
b0801e4
fix changelog format
Plyb Apr 15, 2023
ff41fec
Apply suggestions from code review
Plyb Apr 19, 2023
d17156a
use upper camel case for prisma schema
Plyb Apr 19, 2023
6c28b32
include originalMessageId
Plyb Apr 19, 2023
c1dc725
only use emojis from the guild
Plyb Apr 19, 2023
2226bd1
fix tests
Plyb Apr 19, 2023
97bfd6d
add db migrations to the setup script.
Plyb Apr 22, 2023
6e155c7
Merge branch '13-starboard' of https://github.com/BYU-CS-Discord/CSBo…
Plyb Apr 22, 2023
582b2fe
Just discord minvalue option
Plyb Apr 25, 2023
05bf145
Merge branch 'main' into 13-starboard
Plyb Jul 8, 2023
052a256
13 update changelog
Plyb Jul 8, 2023
44c980e
13 repair tests
Plyb Jul 8, 2023
48503f2
Revert "13 repair tests"
Plyb Jul 8, 2023
3e0cc4a
Update src/reactionHandlers/updateReactboard.ts
Plyb Jul 14, 2023
3c24c99
Merge branch 'main' into 13-starboard
JstnMcBrd Jan 19, 2024
1551f8b
Migrate reactboard tests to vitest
JstnMcBrd Jan 19, 2024
d8f8075
Cleanup merge
JstnMcBrd Jan 19, 2024
ecb1ffe
Lockfile v3
JstnMcBrd Jan 19, 2024
6d15f39
Merge branch 'main' into 13-starboard
JstnMcBrd Feb 5, 2024
783e0eb
Fix lint errors
JstnMcBrd Feb 5, 2024
237114f
Fix lint errors
JstnMcBrd Feb 5, 2024
3549be7
Mock logger
JstnMcBrd Feb 5, 2024
def14bb
Merge branch 'main' into 13-starboard
JstnMcBrd Feb 7, 2024
b1a661f
Cleanup from merge
JstnMcBrd Feb 7, 2024
33a457f
Merge branch 'main' into 13-starboard
JstnMcBrd Feb 7, 2024
c417473
Merge branch 'main' into 13-starboard
AverageHelper Apr 8, 2024
3bfaee1
Merge branch 'main' into 13-starboard
JstnMcBrd Aug 24, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,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.
JstnMcBrd marked this conversation as resolved.
Show resolved Hide resolved

### Setting up Docker

Expand Down
11 changes: 11 additions & 0 deletions prisma/migrations/20230411151150_reactboard/migration.sql
AverageHelper marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -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")
);
Original file line number Diff line number Diff line change
@@ -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");
12 changes: 12 additions & 0 deletions prisma/migrations/20230411182041_reactboard_posts/migration.sql
Original file line number Diff line number Diff line change
@@ -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;
51 changes: 21 additions & 30 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,27 @@ 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 {
AverageHelper marked this conversation as resolved.
Show resolved Hide resolved
id Int @id @default(autoincrement())
reactboardId Int
reactboard reactboard @relation(fields: [reactboardId], references: [id])
originalMessageId String
AverageHelper marked this conversation as resolved.
Show resolved Hide resolved
reactboardMessageId String
}

model Scoreboard {
id Int @id @default(autoincrement())
userId String
Expand All @@ -18,23 +39,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())
Expand All @@ -49,16 +53,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
// }
12 changes: 12 additions & 0 deletions src/@types/ReactionHandler.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { MessageReaction, PartialMessageReaction, PartialUser, User } from 'discord.js';
Plyb marked this conversation as resolved.
Show resolved Hide resolved

declare global {
interface ReactionHandler {
execute: (context: ReactionHandlerContext) => void | Promise<void>;
}

interface ReactionHandlerContext {
reaction: MessageReaction | PartialMessageReaction;
user: User | PartialUser;
}
}
2 changes: 2 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { update } from './update';
import { findRoom } from './findRoom';
import { stats } from './stats';
import { emoji } from './emoji';
import { setReactboard } from './setReactboard';

_add(help);
_add(xkcd);
Expand All @@ -54,3 +55,4 @@ _add(toTheGallows);
_add(update);
_add(stats);
_add(emoji);
_add(setReactboard);
121 changes: 121 additions & 0 deletions src/commands/setReactboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import type { PrismaClient } from '@prisma/client';
import { DeepMockProxy, mockDeep } from 'jest-mock-extended';
import type { TextChannel } from 'discord.js';

jest.mock('../database', () => ({
db: mockDeep<PrismaClient>(),
}));

import { setReactboard } from './setReactboard';
import { db } from '../database';
import { UserMessageError } from '../helpers/UserMessageError';

describe('setReactboard', () => {
const dbMock = db as unknown as DeepMockProxy<PrismaClient>;
/* eslint-disable @typescript-eslint/unbound-method */
const mockUpsert = dbMock.reactboard.upsert;
/* eslint-enable @typescript-eslint/unbound-method */

const mockGuildId = 'test-guild-id';
const mockChannel = {
id: 'test-channel-id',
};
const mockThreshold = 5;
const mockReact = '⭐';

const mockReplyPrivately = jest.fn();
const mockGetString = jest.fn<string | null, [name: string]>();
const mockGetInteger = jest.fn<number, [name: string]>();
const mockGetChannel = jest.fn<TextChannel | null, [name: string]>();
const mockGetEmoji = jest.fn();
let context: GuildedCommandContext;

beforeEach(() => {
context = {
replyPrivately: mockReplyPrivately,
options: {
getString: mockGetString,
getInteger: mockGetInteger,
getChannel: mockGetChannel,
},
client: {
emojis: {
cache: {
get: mockGetEmoji,
},
},
},
guild: {
id: mockGuildId,
},
} as unknown as GuildedCommandContext;

mockGetChannel.mockReturnValue(mockChannel as unknown as TextChannel);
mockGetInteger.mockReturnValue(mockThreshold);
mockGetString.mockReturnValue(mockReact);
});

test('upserts a reactboard', async () => {
await expect(setReactboard.execute(context)).resolves.toBeUndefined();
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 expect(setReactboard.execute(context)).resolves.toBeUndefined();
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);
});
});
97 changes: 97 additions & 0 deletions src/commands/setReactboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { Client, GuildEmoji, SlashCommandBuilder } from 'discord.js';
import { UserMessageError } from '../helpers/UserMessageError';
import { db } from '../database';

const channelOption = 'channel';
const thresholdOption = 'threshold';
const reactOption = 'react';

const builder = new SlashCommandBuilder()
.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'
)
Plyb marked this conversation as resolved.
Show resolved Hide resolved
.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, client, 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 = getCustomReact(client, react);
if (customReact === undefined) {
throw new UserMessageError('React option must be a valid reaction');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For clarity:

Suggested change
throw new UserMessageError('React option must be a valid reaction');
throw new UserMessageError('React option must be a valid emoji');

I don't think you can send a "reaction" as a command argument per-se, but emojis are fair game :P

}
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!');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally this would say something different depending on whether the board was created or updated or destroyed, but since we don't differentiate that rn, more generic language might be best:

Suggested change
await replyPrivately('Reactboard created!');
await replyPrivately('Reactboard set!');

},
};

function isUnicodeEmoji(str: string): boolean {
return Boolean(str.match(/^\p{Extended_Pictographic}$/u));
}

function getCustomReact(client: Client<true>, str: string): GuildEmoji | undefined {
const reactId = str.match(/^<a?:.+?:(\d+?)>$/u)?.[1];
if (reactId === undefined) {
return undefined;
}
return client.emojis.cache.get(reactId);
}
AverageHelper marked this conversation as resolved.
Show resolved Hide resolved
2 changes: 2 additions & 0 deletions src/events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,11 @@ import { error } from './error';
import { interactionCreate } from './interactionCreate';
import { messageReactionAdd } from './messageReactionAdd';
import { ready } from './ready';
import { messageReactionRemove } from './messageReactionRemove';

_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
Loading