diff --git a/README.md b/README.md index 04be67ca..03cc4004 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. diff --git a/package.json b/package.json index c918c6b7..aa277854 100644 --- a/package.json +++ b/package.json @@ -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": [ @@ -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", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 94877c69..e6369e93 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) @@ -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 diff --git a/src/assets/bonk.webp b/src/assets/bonk.webp new file mode 100644 index 00000000..8327bd98 Binary files /dev/null and b/src/assets/bonk.webp differ diff --git a/src/assets/smite.gif b/src/assets/smite.gif new file mode 100644 index 00000000..c8caf7bb Binary files /dev/null and b/src/assets/smite.gif differ diff --git a/src/assets/whack.gif b/src/assets/whack.gif new file mode 100644 index 00000000..488f72c6 Binary files /dev/null and b/src/assets/whack.gif differ diff --git a/src/commands/index.ts b/src/commands/index.ts index 9752f087..92f51adf 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -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'; @@ -54,7 +56,9 @@ _add(profile); _add(scrapeRooms); _add(sendtag); _add(setReactboard); +_add(smite); _add(stats); +_add(unsmite); _add(talk); _add(toTheGallows); _add(xkcd); diff --git a/src/commands/smite.test.ts b/src/commands/smite.test.ts new file mode 100644 index 00000000..0392edea --- /dev/null +++ b/src/commands/smite.test.ts @@ -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(); + const mockGetUser = vi.fn(); + 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)], + }) + ); + }); +}); diff --git a/src/commands/smite.ts b/src/commands/smite.ts new file mode 100644 index 00000000..ab4cfe08 --- /dev/null +++ b/src/commands/smite.ts @@ -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 { + 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()], + }); + }, +}; diff --git a/src/commands/unsmite.test.ts b/src/commands/unsmite.test.ts new file mode 100644 index 00000000..19fd82f5 --- /dev/null +++ b/src/commands/unsmite.test.ts @@ -0,0 +1,77 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { User } from 'discord.js'; +import { unsmite } from './unsmite.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('unsmite', () => { + const mockReply = vi.fn(); + const mockGetUser = vi.fn(); + + let context: GuildedCommandContext; + let mockTargetUser: User; + + beforeEach(() => { + vi.clearAllMocks(); + + mockTargetUser = { + id: 'target-user-id', + username: 'TargetUser', + } as User; + + context = { + reply: mockReply, + options: { + getUser: mockGetUser, + }, + member: { + id: 'executing-user-id', + }, + guild: { + id: 'test-guild-id', + }, + } as unknown as GuildedCommandContext; + + mockGetUser.mockReturnValue(mockTargetUser); + }); + + it('should throw error if executor is not an admin', async () => { + vi.mocked(smiteUtils.isAdmin).mockReturnValue(false); + + await expect(unsmite.execute(context)).rejects.toThrow(UserMessageError); + await expect(unsmite.execute(context)).rejects.toThrow("You don't have permission"); + }); + + it('should successfully unsmite a user', async () => { + vi.mocked(smiteUtils.isAdmin).mockReturnValue(true); + + await unsmite.execute(context); + + expect(smiteUtils.setUserSmitten).toHaveBeenCalledWith( + mockTargetUser.id, + 'test-guild-id', + false + ); + expect(mockReply).toHaveBeenCalledWith( + expect.objectContaining({ + embeds: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + title: '✨ Mercy Granted ✨', + }), + }), + ]), + }) + ); + }); +}); diff --git a/src/commands/unsmite.ts b/src/commands/unsmite.ts new file mode 100644 index 00000000..d0141e27 --- /dev/null +++ b/src/commands/unsmite.ts @@ -0,0 +1,38 @@ +import { EmbedBuilder, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { isAdmin, setUserSmitten } from '../helpers/smiteUtils.js'; +import { UserMessageError } from '../helpers/UserMessageError.js'; + +const builder = new SlashCommandBuilder() + .setName('unsmite') + .setDescription('[ADMIN] Unsmite a user, allowing them to use bot commands again') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addUserOption(option => + option.setName('user').setDescription('The user to unsmite').setRequired(true) + ); + +export const unsmite: GuildedCommand = { + info: builder, + requiresGuild: true, + async execute({ reply, options, member, guild }): Promise { + const targetUser = options.getUser('user', true); + + // Check if the executor is an admin + if (!isAdmin(member)) { + throw new UserMessageError("You don't have permission to use this command."); + } + + // Unsmite the target user + await setUserSmitten(targetUser.id, guild.id, false); + + await reply({ + embeds: [ + new EmbedBuilder() + .setTitle('✨ Mercy Granted ✨') + .setDescription( + `${targetUser} has been unsmitten!\n\nThey can now use bot commands again.` + ) + .setColor(0x57_f2_87), // Green + ], + }); + }, +}; diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index fe9fb92c..f58ee75d 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -20,6 +20,7 @@ import { DISCORD_API_MAX_CHOICES } from '../constants/apiLimitations.js'; import { logUser } from '../helpers/logUser.js'; import { onEvent } from '../helpers/onEvent.js'; import { UserMessageError } from '../helpers/UserMessageError.js'; +import { isUserSmitten, isAdmin } from '../helpers/smiteUtils.js'; import { debug, error, warn } from '../logger.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -35,6 +36,32 @@ export const interactionCreate = onEvent('interactionCreate', { if (interaction.user.bot) return; if (interaction.user.id === interaction.client.user.id) return; + // Check if user is smitten (but allow autocomplete for UX and admin smite commands) + if (interaction.guild && !interaction.isAutocomplete()) { + const smitten = await isUserSmitten(interaction.user.id, interaction.guild.id); + if (smitten) { + // Allow smitten admins to use smite/unsmite commands + const isCommand = interaction.isCommand(); + const commandName = isCommand ? interaction.commandName : null; + const isSmiteCommand = commandName === 'smite' || commandName === 'unsmite'; + + if (isSmiteCommand && interaction.member) { + // Check if the smitten user is an admin + const member = await interaction.guild.members.fetch(interaction.user.id); + if (isAdmin(member)) { + debug(`Smitten admin ${logUser(interaction.user)} allowed to use ${commandName}`); + // Allow the command to proceed + } else { + debug(`User ${logUser(interaction.user)} is smitten, ignoring interaction`); + return; // Silently ignore the interaction + } + } else { + debug(`User ${logUser(interaction.user)} is smitten, ignoring interaction`); + return; // Silently ignore the interaction + } + } + } + if (interaction.isCommand()) { const context = await generateContext(interaction); await handleCommandInteraction(context, interaction); diff --git a/src/events/ready.ts b/src/events/ready.ts index e5811d29..4944bf2b 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -10,6 +10,7 @@ import { parseArgs } from '../helpers/parseArgs.js'; import { verifyCommandDeployments } from '../helpers/actions/verifyCommandDeployments.js'; import { info } from '../logger.js'; import { setupScraperCron } from '../roomFinder/cron.js'; +import { setupAutoUnsmiteCron } from '../helpers/smiteCron.js'; /** * The event handler for when the Discord Client is ready for action @@ -51,6 +52,10 @@ export const ready = onEvent('ready', { // setupScraperCron('0 0 1 * *'); // Midnight on 1st of month // setupScraperCron('0 2 * * 0', '20251'); // Specific semester + // Set up automatic unsmiting of users after 1 hour + // This will check every minute and unsmite anyone who has been smitten for over an hour + setupAutoUnsmiteCron(); // Default: '* * * * *' (every minute) + // Start uptime ping const UPTIME_URL = process.env['UPTIME_URL']; if (UPTIME_URL) { diff --git a/src/helpers/smiteCron.ts b/src/helpers/smiteCron.ts new file mode 100644 index 00000000..fcc1dfca --- /dev/null +++ b/src/helpers/smiteCron.ts @@ -0,0 +1,40 @@ +import { CronJob } from 'cron'; +import { autoUnsmiteExpiredUsers } from './smiteUtils.js'; +import { info, error } from '../logger.js'; + +/** + * Sets up automatic unsmiting of users after a specified duration + * Runs on a cron schedule to check for and unsmite users who have been smitten for too long + */ +export function setupAutoUnsmiteCron( + cronSchedule: string = '* * * * *', + maxDurationMs: number = 3_600_000 +): CronJob { + info(`[CRON] Setting up auto-unsmite cron: ${cronSchedule} (max duration: ${maxDurationMs}ms)`); + + const job = new CronJob( + cronSchedule, + async () => { + info('[CRON] Running auto-unsmite check...'); + + try { + const count = await autoUnsmiteExpiredUsers(maxDurationMs); + + if (count > 0) { + info(`[CRON] Auto-unsmitten ${count} user(s)`); + } else { + info('[CRON] No users needed auto-unsmiting'); + } + } catch (error_) { + error('[CRON] Auto-unsmite check failed:'); + error(error_); + } + }, + null, + true, + 'America/Denver' + ); + + info('[CRON] Auto-unsmite cron job started'); + return job; +} diff --git a/src/helpers/smiteUtils.test.ts b/src/helpers/smiteUtils.test.ts new file mode 100644 index 00000000..c7017d72 --- /dev/null +++ b/src/helpers/smiteUtils.test.ts @@ -0,0 +1,142 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PrismaClient } from '@prisma/client'; +import { isUserSmitten, setUserSmitten, autoUnsmiteExpiredUsers } from './smiteUtils.js'; +import { db } from '../database/index.js'; + +vi.mock('../database/index.js', () => ({ + db: new PrismaClient(), +})); + +describe('smiteUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('isUserSmitten', () => { + it('should return false when user does not exist', async () => { + vi.spyOn(db.user, 'findUnique').mockResolvedValue(null); + + const result = await isUserSmitten('user123', 'guild456'); + + expect(result).toBe(false); + expect(db.user.findUnique).toHaveBeenCalledWith({ + where: { + user_guild: { + userId: 'user123', + guildId: 'guild456', + }, + }, + }); + }); + + it('should return true when user is smitten', async () => { + vi.spyOn(db.user, 'findUnique').mockResolvedValue({ + id: 1, + userId: 'user123', + guildId: 'guild456', + smitten: true, + smittenAt: new Date(), + }); + + const result = await isUserSmitten('user123', 'guild456'); + + expect(result).toBe(true); + }); + + it('should return false when user exists but is not smitten', async () => { + vi.spyOn(db.user, 'findUnique').mockResolvedValue({ + id: 1, + userId: 'user123', + guildId: 'guild456', + smitten: false, + smittenAt: null, + }); + + const result = await isUserSmitten('user123', 'guild456'); + + expect(result).toBe(false); + }); + }); + + describe('setUserSmitten', () => { + it('should create a new user when they do not exist', async () => { + vi.spyOn(db.user, 'upsert').mockResolvedValue({ + id: 1, + userId: 'user123', + guildId: 'guild456', + smitten: true, + smittenAt: new Date(), + }); + + await setUserSmitten('user123', 'guild456', true); + + const call = vi.mocked(db.user.upsert).mock.calls[0][0]; + expect(call.where).toEqual({ + user_guild: { + userId: 'user123', + guildId: 'guild456', + }, + }); + expect(call.update.smitten).toBe(true); + expect(call.update.smittenAt).toBeInstanceOf(Date); + expect(call.create.smitten).toBe(true); + expect(call.create.smittenAt).toBeInstanceOf(Date); + }); + + it('should update an existing user and clear timestamp when unsmiting', async () => { + vi.spyOn(db.user, 'upsert').mockResolvedValue({ + id: 1, + userId: 'user123', + guildId: 'guild456', + smitten: false, + smittenAt: null, + }); + + await setUserSmitten('user123', 'guild456', false); + + const call = vi.mocked(db.user.upsert).mock.calls[0][0]; + expect(call.update.smitten).toBe(false); + expect(call.update.smittenAt).toBe(null); + expect(call.create.smitten).toBe(false); + expect(call.create.smittenAt).toBe(null); + }); + }); + + describe('autoUnsmiteExpiredUsers', () => { + it('should return count of users unsmitten', async () => { + const oldDate = new Date(Date.now() - 7_200_000); // 2 hours ago + vi.spyOn(db.user, 'findMany').mockResolvedValue([ + { + id: 1, + userId: 'user123', + guildId: 'guild456', + smitten: true, + smittenAt: oldDate, + }, + ]); + vi.spyOn(db.user, 'updateMany').mockResolvedValue({ count: 1 }); + + const result = await autoUnsmiteExpiredUsers(3_600_000); // 1 hour + + expect(result).toBe(1); + expect(db.user.findMany).toHaveBeenCalled(); + expect(db.user.updateMany).toHaveBeenCalled(); + + const call = vi.mocked(db.user.updateMany).mock.calls[0][0]; + expect(call.where?.smitten).toBe(true); + expect(call.data.smitten).toBe(false); + expect(call.data.smittenAt).toBe(null); + }); + + it('should return 0 when no users need unsmiting', async () => { + vi.spyOn(db.user, 'findMany').mockResolvedValue([]); + vi.spyOn(db.user, 'updateMany').mockResolvedValue({ count: 0 }); + + const result = await autoUnsmiteExpiredUsers(); + + expect(result).toBe(0); + expect(db.user.updateMany).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/helpers/smiteUtils.ts b/src/helpers/smiteUtils.ts new file mode 100644 index 00000000..d36a463d --- /dev/null +++ b/src/helpers/smiteUtils.ts @@ -0,0 +1,99 @@ +import type { GuildMember } from 'discord.js'; +import { PermissionFlagsBits } from 'discord.js'; +import { db } from '../database/index.js'; + +/** + * Checks if a user is smitten in a specific guild. + * @param userId Discord user ID + * @param guildId Discord guild ID + * @returns true if user is smitten, false otherwise + */ +export async function isUserSmitten(userId: string, guildId: string): Promise { + const user = await db.user.findUnique({ + where: { + user_guild: { + userId, + guildId, + }, + }, + }); + + return user?.smitten ?? false; +} + +/** + * Sets the smitten status for a user in a guild. + * @param userId Discord user ID + * @param guildId Discord guild ID + * @param smitten Whether the user should be smitten + */ +export async function setUserSmitten( + userId: string, + guildId: string, + smitten: boolean +): Promise { + await db.user.upsert({ + where: { + user_guild: { + userId, + guildId, + }, + }, + update: { + smitten, + smittenAt: smitten ? new Date() : null, + }, + create: { + userId, + guildId, + smitten, + smittenAt: smitten ? new Date() : null, + }, + }); +} + +/** + * Auto-unsmites users who have been smitten for longer than the specified duration. + * @param maxDurationMs Maximum duration in milliseconds (default: 1 hour) + * @returns Number of users auto-unsmitten + */ +export async function autoUnsmiteExpiredUsers(maxDurationMs: number = 3_600_000): Promise { + const cutoffTime = new Date(Date.now() - maxDurationMs); + + const expiredUsers = await db.user.findMany({ + where: { + smitten: true, + smittenAt: { + lte: cutoffTime, + }, + }, + }); + + if (expiredUsers.length === 0) { + return 0; + } + + await db.user.updateMany({ + where: { + smitten: true, + smittenAt: { + lte: cutoffTime, + }, + }, + data: { + smitten: false, + smittenAt: null, + }, + }); + + return expiredUsers.length; +} + +/** + * Checks if a guild member has administrator permissions. + * @param member Guild member to check + * @returns true if member is an administrator, false otherwise + */ +export function isAdmin(member: GuildMember): boolean { + return member.permissions.has(PermissionFlagsBits.Administrator); +}