Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ Not complete. For now, this command simply auto-completes the tag the user types

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.

### /smite

Temporarily prevents a user from using bot commands for one hour. Only administrators can successfully use this command - non-admins who attempt to use it will be smitten for 60 seconds. Administrators cannot be smitten, and attempting to smite the bot will result in the executor being smitten instead. Users who smite themselves receive a special response.

### /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.
Expand All @@ -114,6 +118,10 @@ By using this command, you are acknowleding that your input will be sent to a th

Begins a new game of Evil Hangman.

### /unsmite

**[Admin Only by default]** Removes the smite status from a user, restoring their ability to use bot commands immediately.

### /xkcd

Retrieves the most recent [xkcd](https://xkcd.com/) comic, or the given one.
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "csbot",
"version": "0.15.0",
"version": "0.16.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": [
Expand All @@ -21,7 +21,7 @@
"type": "module",
"main": "./dist/main.js",
"scripts": {
"build": "rm -rf dist && ./node_modules/.bin/tsc -p tsconfig.build.json && npm run db:generate",
"build": "rm -rf dist && ./node_modules/.bin/tsc -p tsconfig.build.json && cp -r src/assets dist/assets && npm run db:generate",
"commands:deploy": "node --env-file=.env . --deploy # TODO: Replace these with automatic command deployment",
"commands:revoke": "node --env-file=.env . --revoke",
"db:generate": "./node_modules/.bin/prisma generate --no-hints --schema ./prisma/schema.prisma",
Expand Down
10 changes: 5 additions & 5 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ model Events {
model User {
id Int @id @default(autoincrement())
userId String @unique // Discord user ID
guildId String // Discord guild ID
guildId String // Discord guild ID
smitten Boolean @default(false)
smittenAt DateTime? // When the user was smitten (null if not smitten)

Expand All @@ -79,10 +79,10 @@ 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
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

Expand Down
Binary file added src/assets/bonk.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/smite.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/whack.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ 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 { talk } from './talk.js';
import { toTheGallows } from './toTheGallows.js';
Expand All @@ -54,7 +56,9 @@ _add(profile);
_add(scrapeRooms);
_add(sendtag);
_add(setReactboard);
_add(smite);
_add(stats);
_add(unsmite);
_add(talk);
_add(toTheGallows);
_add(xkcd);
Expand Down
178 changes: 178 additions & 0 deletions src/commands/smite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Client, User } from 'discord.js';
import { AttachmentBuilder } from 'discord.js';
import { smite } from './smite.js';
import * as smiteUtils from '../helpers/smiteUtils.js';
import { UserMessageError } from '../helpers/UserMessageError.js';

vi.mock('../helpers/smiteUtils.js', async () => {
const actual = await vi.importActual('../helpers/smiteUtils.js');
return {
...actual,
isAdmin: vi.fn(),
setUserSmitten: vi.fn(),
};
});

describe('smite', () => {
const mockReply = vi.fn<GuildedCommandContext['reply']>();
const mockGetUser = vi.fn<GuildedCommandContext['options']['getUser']>();
const mockFetch = vi.fn();

let context: GuildedCommandContext;
let mockTargetUser: User;
let mockExecutingUser: User;
let mockClient: Client;

beforeEach(() => {
vi.clearAllMocks();

mockTargetUser = {
id: 'target-user-id',
username: 'TargetUser',
} as User;

mockExecutingUser = {
id: 'executing-user-id',
username: 'ExecutingUser',
} as User;

mockClient = {
user: {
id: 'bot-user-id',
username: 'TestBot',
},
} as Client;

context = {
reply: mockReply,
options: {
getUser: mockGetUser,
},
member: {
id: 'executing-user-id',
},
user: mockExecutingUser,
guild: {
id: 'test-guild-id',
members: {
fetch: mockFetch,
},
},
client: mockClient,
} as unknown as GuildedCommandContext;

mockGetUser.mockReturnValue(mockTargetUser);
mockFetch.mockResolvedValue({
id: 'target-user-id',
});
});

it('should smite non-admin executor for 60 seconds when they try to use the command', async () => {
vi.mocked(smiteUtils.isAdmin).mockReturnValue(false);

await smite.execute(context);

expect(smiteUtils.setUserSmitten).toHaveBeenCalledWith(
mockExecutingUser.id,
'test-guild-id',
true
);
expect(mockReply).toHaveBeenCalledWith(
expect.objectContaining({
embeds: expect.arrayContaining([
expect.objectContaining({
data: expect.objectContaining({
title: '⚡ Hubris! ⚡',
image: expect.objectContaining({ url: 'attachment://smite.gif' }),
}),
}),
]),
files: [expect.any(AttachmentBuilder)],
})
);
});

it('should show wack image if user tries to smite themselves', async () => {
vi.mocked(smiteUtils.isAdmin).mockReturnValue(true);
mockTargetUser.id = mockExecutingUser.id;
mockGetUser.mockReturnValue(mockTargetUser);

await smite.execute(context);

expect(mockReply).toHaveBeenCalledWith(
expect.objectContaining({
embeds: expect.arrayContaining([
expect.objectContaining({
data: expect.objectContaining({
title: 'Wack.',
image: expect.objectContaining({ url: 'attachment://wack.webp' }),
}),
}),
]),
files: [expect.any(AttachmentBuilder)],
})
);
expect(smiteUtils.setUserSmitten).not.toHaveBeenCalled();
});

it('should smite the executor if they try to smite the bot', async () => {
vi.mocked(smiteUtils.isAdmin).mockReturnValue(true);
mockTargetUser.id = mockClient.user?.id ?? '';
mockGetUser.mockReturnValue(mockTargetUser);

await smite.execute(context);

expect(smiteUtils.setUserSmitten).toHaveBeenCalledWith(
mockExecutingUser.id,
'test-guild-id',
true
);
expect(mockReply).toHaveBeenCalledWith(
expect.objectContaining({
embeds: expect.arrayContaining([
expect.objectContaining({
data: expect.objectContaining({
title: 'You fool!',
image: expect.objectContaining({ url: 'attachment://smite.gif' }),
}),
}),
]),
files: [expect.any(AttachmentBuilder)],
})
);
});

it('should throw error if target is an admin', async () => {
vi.mocked(smiteUtils.isAdmin).mockReturnValueOnce(true).mockReturnValueOnce(true);

await expect(smite.execute(context)).rejects.toThrow(UserMessageError);
await expect(smite.execute(context)).rejects.toThrow('cannot smite an administrator');
});

it('should successfully smite a regular user', async () => {
vi.mocked(smiteUtils.isAdmin).mockReturnValueOnce(true).mockReturnValueOnce(false);

await smite.execute(context);

expect(smiteUtils.setUserSmitten).toHaveBeenCalledWith(
mockTargetUser.id,
'test-guild-id',
true
);
expect(mockReply).toHaveBeenCalledWith(
expect.objectContaining({
embeds: expect.arrayContaining([
expect.objectContaining({
data: expect.objectContaining({
title: '⚡ SMITTEN! ⚡',
image: expect.objectContaining({ url: 'attachment://smite.gif' }),
}),
}),
]),
files: [expect.any(AttachmentBuilder)],
})
);
});
});
111 changes: 111 additions & 0 deletions src/commands/smite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { AttachmentBuilder, EmbedBuilder, SlashCommandBuilder } from 'discord.js';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { isAdmin, setUserSmitten } from '../helpers/smiteUtils.js';
import { UserMessageError } from '../helpers/UserMessageError.js';

const builder = new SlashCommandBuilder()
.setName('smite')
.setDescription('Smite a user, preventing them from using bot commands')
.addUserOption(option =>
option.setName('user').setDescription('The user to smite').setRequired(true)
);

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const assetsDir = path.resolve(__dirname, '..', 'assets');

function smiteGifAttachment(): AttachmentBuilder {
return new AttachmentBuilder(path.join(assetsDir, 'smite.gif'), { name: 'smite.gif' });
}

function wackImageAttachment(): AttachmentBuilder {
return new AttachmentBuilder(path.join(assetsDir, 'bonk.webp'), { name: 'bonk.webp' });
}

const ODIN_SMITING_THOR_GIF = 'attachment://smite.gif';
const WACK_IMAGE = 'attachment://bonk.webp';
export const smite: GuildedCommand = {
info: builder,
requiresGuild: true,
async execute({ reply, options, member, guild, client, user }): Promise<void> {
const targetUser = options.getUser('user', true);
const targetMember = await guild.members.fetch(targetUser.id);

// Check if the executor is an admin
if (!isAdmin(member)) {
// Non-admins get smitten for 60 seconds for trying to use this command
await setUserSmitten(user.id, guild.id, true);

// Auto-unsmite after 60 seconds
setTimeout(() => {
void setUserSmitten(user.id, guild.id, false);
}, 60_000);

await reply({
embeds: [
new EmbedBuilder()
.setTitle('⚡ Hubris! ⚡')
.setDescription(
`You dare try to wield the power of the gods?\n\nYou have been smitten for 60 seconds for your insolence!`
)
.setImage(ODIN_SMITING_THOR_GIF)
.setColor(0xff_00_00), // Red
],
files: [smiteGifAttachment()],
});
return;
}

// Check if user is trying to smite themselves
if (targetUser.id === user.id) {
await reply({
embeds: [
new EmbedBuilder().setTitle('Wack.').setImage(WACK_IMAGE).setColor(0xff_a5_00), // Orange
],
files: [wackImageAttachment()],
});
return;
}

// Check if user is trying to smite the bot
if (targetUser.id === client.user.id) {
// Smite the user who tried to smite the bot instead
await setUserSmitten(user.id, guild.id, true);

await reply({
embeds: [
new EmbedBuilder()
.setTitle('You fool!')
.setDescription(
`Only now do you understand.\n\nYou have been smitten for attempting to smite ${client.user.username}.`
)
.setImage(ODIN_SMITING_THOR_GIF)
.setColor(0xff_00_00), // Red
],
files: [smiteGifAttachment()],
});
return;
}

// Check if target is an admin
if (isAdmin(targetMember)) {
throw new UserMessageError('You cannot smite an administrator.');
}

// Smite the target user
await setUserSmitten(targetUser.id, guild.id, true);

await reply({
embeds: [
new EmbedBuilder()
.setTitle('⚡ SMITTEN! ⚡')
.setDescription(
`${targetUser} has been smitten by the gods!\n\nThey can no longer use bot commands for the next hour.`
)
.setImage(ODIN_SMITING_THOR_GIF)
.setColor(0x58_65_f2), // Blurple
],
files: [smiteGifAttachment()],
});
},
};
Loading