Skip to content
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]** Removes the smite status from a user, restoring their ability to use bot commands immediately.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
**[Admin Only]** Removes the smite status from a user, restoring their ability to use bot commands immediately.
**[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
2 changes: 1 addition & 1 deletion 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 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
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
169 changes: 169 additions & 0 deletions src/commands/smite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Client, User } 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! ⚡',
}),
}),
]),
})
);
});

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.',
}),
}),
]),
})
);
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!',
}),
}),
]),
})
);
});

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! ⚡',
}),
}),
]),
})
);
});
});
96 changes: 96 additions & 0 deletions src/commands/smite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { EmbedBuilder, SlashCommandBuilder } from 'discord.js';
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 ODIN_SMITING_THOR_GIF =
'https://cdn.discordapp.com/attachments/781563734098575410/1083403217553084446/smite.gif?ex=68fb93df&is=68fa425f&hm=83b4a865179f0dd6fb1312f535c2a730673709734470c0095dd2a432908bfcba&';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This link has expired already. We should instead provide the image file directly.

const WACK_IMAGE =
'https://i.kym-cdn.com/entries/icons/original/000/033/758/Screen_Shot_2020-04-28_at_12.21.48_PM.png';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This link still works, but feels weird to rely on an external service for a file we may frequently access. We should provide this file directly as well

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

maybe even convert to .webp for smaller storage, unless we have ppl using old versions of Safari lol

@AverageHelper AverageHelper Dec 6, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I wonder if we should make a simple HTTP server in CSBot that serves static images like these ones 🤔

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
],
});
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
],
});
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
],
});
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
],
});
},
};
Loading