From fe020174574ef8ce11323722871410b51944f81a Mon Sep 17 00:00:00 2001 From: theimperious1 Date: Tue, 24 Dec 2024 21:53:58 -0500 Subject: [PATCH 1/4] Initial /watch_user implementation --- src/discord/commands/guild/d.watchuser.ts | 98 +++++++++ src/discord/events/messageCreate.ts | 2 + src/global/commands/g.watchuser.ts | 194 ++++++++++++++++++ .../migration.sql | 14 ++ src/prisma/tripbot/schema.prisma | 23 ++- 5 files changed, 325 insertions(+), 6 deletions(-) create mode 100644 src/discord/commands/guild/d.watchuser.ts create mode 100644 src/global/commands/g.watchuser.ts create mode 100644 src/prisma/tripbot/migrations/20241225024135_watch_requests/migration.sql diff --git a/src/discord/commands/guild/d.watchuser.ts b/src/discord/commands/guild/d.watchuser.ts new file mode 100644 index 000000000..d5db43bbb --- /dev/null +++ b/src/discord/commands/guild/d.watchuser.ts @@ -0,0 +1,98 @@ +import { + ChannelType, + GuildMember, + SlashCommandBuilder, + TextChannel, + Role, +} from 'discord.js'; +import { SlashCommand } from '../../@types/commandDef'; +import commandContext from '../../utils/context'; // eslint-disable-line @typescript-eslint/no-unused-vars +import { executeWatch, deleteWatchRequest } from '../../../global/commands/g.watchuser'; + +const F = f(__filename); + +export const dWatchUser: SlashCommand = { + data: new SlashCommandBuilder() + .setName('watch') + .setDescription('Get notified when a user says something') + .addSubcommand(subcommand => subcommand + .setName('add') + .setDescription('Set a Watch on a user.') + .addStringOption(option => option.setName('target') + .setDescription('The target user to watch for') + .setRequired(true)) + .addStringOption(option => option.setName('notification_method') + .setDescription('How do you want to be notified?') + .addChoices( + { name: 'DM', value: 'dm' }, + { name: 'Channel', value: 'channel' }, + ) + .setRequired(true)) + .addChannelOption(option => option.setName('alert_channel') + .setDescription('Where should I notify you? (Default: \'here\')'))) + .addSubcommand(subcommand => subcommand + .setName('cancel') + .setDescription('Cancel a Watch on a user') + .addStringOption(option => option.setName('target') + .setDescription('The target user you were watching') + .setRequired(true))), + async execute(interaction) { + log.info(F, await commandContext(interaction)); + await interaction.deferReply({ ephemeral: true }); + + if (!interaction.guild) { + await interaction.editReply({ content: 'This command can only be used in a server!' }); + return false; + } + + if (!(interaction.member as GuildMember).roles.cache.some( + (role: Role) => (role.id === env.ROLE_MODERATOR || role.id === env.DEVELOPER), + )) { + await interaction.reply('You do not have permission to use this command.'); + return false; + } + + const targetUserId = interaction.options.getString('target', true); + + if (interaction.options.getSubcommand() === 'cancel') { + if (await deleteWatchRequest(targetUserId, interaction.user.id)) { + await interaction.editReply({ content: 'Done! You won\'t be notified the next time this user is active.' }); + return true; + } + // eslint-disable-next-line max-len + await interaction.editReply({ content: 'Whoops, it seems like you don\'t have any watch requests on this user to cancel!' }); + return false; + } + + let alertChannel = interaction.options.getChannel('alert_channel') as TextChannel | null; + + if (!alertChannel) { + alertChannel = interaction.channel as TextChannel; + } + + // Ensure that the channel used is a text channel + if (alertChannel.type !== ChannelType.GuildText) { + await interaction.editReply({ content: 'This command can only be used in a text channel!' }); + return false; + } + + const notificationMethod = interaction.options.getString('notification_method', true); + const target = await interaction.client.users.fetch(targetUserId); + + if (await executeWatch(target, notificationMethod, interaction.user.id, alertChannel)) { + await interaction.editReply({ content: 'Done! You\'ll be notified when this user is next seen active.' }); + + const channelBotlog = await interaction.guild.channels.fetch(env.CHANNEL_BOTLOG) as TextChannel; + if (channelBotlog) { + await channelBotlog.send(`${(interaction.member as GuildMember).displayName} used /watch on ${target}`); + } + } else { + // eslint-disable-next-line max-len + await interaction.editReply({ content: 'Whoops, it seems like you\'re already watching this user! If you\'d like, you can cancel this or cancel and switch modes, by using /watch cancel.' }); + } + + return true; + }, +}; + +export default dWatchUser; diff --git a/src/discord/events/messageCreate.ts b/src/discord/events/messageCreate.ts index 4646a15dd..de8536a26 100644 --- a/src/discord/events/messageCreate.ts +++ b/src/discord/events/messageCreate.ts @@ -17,6 +17,7 @@ import { imagesOnly } from '../utils/imagesOnly'; import { countMessage } from '../commands/guild/d.counting'; import { bridgeMessage } from '../utils/bridge'; import { discordAiModerate } from '../commands/global/d.ai'; +import { nightsWatch } from '../../global/commands/g.watchuser'; // import { awayMessage } from '../utils/awayMessage'; // import log from '../../global/utils/log'; // import {parse} from 'path'; @@ -89,6 +90,7 @@ export const messageCreate: MessageCreateEvent = { karma(message); imagesOnly(message); discordAiModerate(message); + nightsWatch(message); // Disabled for testing // thoughtPolice(message); diff --git a/src/global/commands/g.watchuser.ts b/src/global/commands/g.watchuser.ts new file mode 100644 index 000000000..2c78efb6d --- /dev/null +++ b/src/global/commands/g.watchuser.ts @@ -0,0 +1,194 @@ +import { + TextChannel, + Message, + User, +} from 'discord.js'; + +const F = f(__filename); + +interface WatchRequest { + notification_method: string; + channel_id: string | null; + caller_id: string; + watched_user_id: string; +} + +type DbWatchRequest = { + id?: string; + notification_method: string; + channel_id: string | null; + caller_id: string; + watched_user_id: string; + usersId?: string; +}; + +export async function dbAddWatchRequest( + targetUserId: string, + watchRequests: WatchRequest[], +) { + const existingUser = await db.users.findUnique({ + where: { + discord_id: targetUserId, + }, + include: { + watch_requests: true, + }, + }); + + if (existingUser) { + // Add new watch requests to the existing user's watch_requests array + const latestRequest = watchRequests[watchRequests.length - 1]; + + return db.users.update({ + where: { + discord_id: targetUserId, + }, + data: { + watch_requests: { + create: { + notification_method: latestRequest.notification_method, + channel_id: latestRequest.channel_id, + caller_id: latestRequest.caller_id, + watched_user_id: latestRequest.watched_user_id, + }, + }, + }, + }); + } + // User not found + throw new Error(`User with ID ${targetUserId} not found.`); +} + +export async function dbDeleteWatchRequest(targetUserId: string, callerId: string): Promise { + const user = await db.users.findUnique({ + where: { discord_id: targetUserId }, + include: { watch_requests: true }, + }); + + if (!user) { + log.info(F, `User with ID ${targetUserId} does not exist.`); + return; + } + + const watchRequests = user.watch_requests as DbWatchRequest[]; + + // Find and delete the request related to the caller + const requestToDelete = watchRequests.find(watchRequestObj => watchRequestObj.caller_id === callerId); + if (requestToDelete) { + await db.watch_request.delete({ + where: { id: requestToDelete.id }, + }); + } else { + log.info(F, `No watch request found for caller ${callerId}.`); + } +} + +export async function deleteWatchRequest(targetUserId: string, callerId: string): Promise { + const user = await db.users.findUnique({ + where: { discord_id: targetUserId }, + include: { watch_requests: true }, + }); + + if (!user) { + return false; // User not found + } + + const watchRequests = user.watch_requests; + + // Find the index of the watch request to delete + const indexToDelete = watchRequests.findIndex(watchRequestObj => watchRequestObj.caller_id === callerId); + if (indexToDelete === -1) { + return false; // WatchRequest with callerId not found + } + // Delete the watch request from the array + watchRequests.splice(indexToDelete, 1); + + await dbDeleteWatchRequest(targetUserId, callerId); + return true; // Successfully deleted the watchRequest +} + +export async function nightsWatch(message: Message) { + const user = await db.users.findUnique({ + where: { discord_id: message.author.id }, + include: { watch_requests: true }, + }); + + if (!user || !message.guild) return; + + const watchRequests = user.watch_requests; + + watchRequests.forEach(async watchRequestObj => { + const target = await message.client.users.fetch(user.discord_id as string) as User; + + if (watchRequestObj.notification_method === 'dm') { + const caller = await message.client.users.fetch(watchRequestObj.caller_id); + if (caller) { + try { + await caller.send( + `Hey ${caller}, the user ${target} which you were watching has been active recently in ${message.channel}.`, + ); + } catch (err) { + log.info(F, 'Failed to fulfill Watch Request. Likely can\'t DM user.'); + } + + await deleteWatchRequest(user.discord_id as string, caller.id); + } + } else if (watchRequestObj.notification_method === 'channel') { + const tripsitGuild = await message.client.guilds.fetch(env.DISCORD_GUILD_ID); + if (watchRequestObj.channel_id) { + // eslint-disable-next-line max-len + const notificationChannel = await tripsitGuild.channels.fetch(watchRequestObj.channel_id as string) as TextChannel; + const caller = await message.client.users.fetch(watchRequestObj.caller_id) as User; + try { + await notificationChannel.send( + `Hey ${caller}, the user ${target} which you were watching has been active recently in ${message.channel}.`, + ); + } catch (err) { + log.info(F, 'Failed to fulfill Watch Request. Notification sending failed.'); + } + await deleteWatchRequest(user.discord_id as string, caller.id); + } + } + }); +} + +export async function executeWatch( + target: User, + notificationMethod: string, + callerId: string, + alertChannel: TextChannel | null = null, +): Promise { + const user = await db.users.findUnique({ + where: { discord_id: target.id }, + include: { watch_requests: true }, + }); + + if (user) { + const watchRequests = user.watch_requests; + + // Check for duplicate request using array iteration + const duplicateRequest = watchRequests.some(watchRequestObj => watchRequestObj.caller_id === callerId); + + if (duplicateRequest) { + log.info(F, `Duplicate watch request found for callerId: ${callerId}`); + return false; + } + + // Add the new watch request + watchRequests.push({ + id: '', + usersId: null, + notification_method: notificationMethod, + channel_id: alertChannel ? alertChannel.id : null, + caller_id: callerId, + watched_user_id: target.id, + }); + await dbAddWatchRequest(target.id, watchRequests); + log.info(F, `New watch request added for callerId: ${callerId}`); + return true; + } + + // If user does not exist, return false + log.info(F, `User ${target.id} not found.`); + return false; +} diff --git a/src/prisma/tripbot/migrations/20241225024135_watch_requests/migration.sql b/src/prisma/tripbot/migrations/20241225024135_watch_requests/migration.sql new file mode 100644 index 000000000..7ac79ba47 --- /dev/null +++ b/src/prisma/tripbot/migrations/20241225024135_watch_requests/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "watch_request" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "notification_method" TEXT NOT NULL, + "channel_id" TEXT, + "caller_id" TEXT NOT NULL, + "watched_user_id" TEXT NOT NULL, + "usersId" UUID, + + CONSTRAINT "watch_request_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "watch_request" ADD CONSTRAINT "watch_request_usersId_fkey" FOREIGN KEY ("usersId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/src/prisma/tripbot/schema.prisma b/src/prisma/tripbot/schema.prisma index 1bdba26cd..750743041 100644 --- a/src/prisma/tripbot/schema.prisma +++ b/src/prisma/tripbot/schema.prisma @@ -175,6 +175,7 @@ model users { connections_scores connections_scores[] mini_scores mini_scores[] last_was_helper DateTime? + watch_requests watch_request[] } model wordle_scores { @@ -187,12 +188,12 @@ model wordle_scores { } model connections_scores { - id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid - user_id String @db.Uuid - puzzle Int - score Int - grid String - user users @relation(fields: [user_id], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "connectionsscores_userid_foreign") + id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + user_id String @db.Uuid + puzzle Int + score Int + grid String + user users @relation(fields: [user_id], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "connectionsscores_userid_foreign") } model mini_scores { @@ -554,6 +555,16 @@ model quotes { @@unique([url], map: "quotes_url_unique") } +model watch_request { + id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + notification_method String + channel_id String? + caller_id String + watched_user_id String + users users? @relation(fields: [usersId], references: [id]) + usersId String? @db.Uuid +} + enum bridge_status { PENDING ACTIVE From 60e24139e565a8a4cc5b2ff3c827f531cf63224f Mon Sep 17 00:00:00 2001 From: theimperious1 Date: Tue, 24 Dec 2024 22:20:12 -0500 Subject: [PATCH 2/4] performance optimization. stop if no watch requests --- src/global/commands/g.watchuser.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/global/commands/g.watchuser.ts b/src/global/commands/g.watchuser.ts index 2c78efb6d..c252d25b1 100644 --- a/src/global/commands/g.watchuser.ts +++ b/src/global/commands/g.watchuser.ts @@ -117,6 +117,10 @@ export async function nightsWatch(message: Message) { const watchRequests = user.watch_requests; + if (watchRequests.length === 0) { + return; + } + watchRequests.forEach(async watchRequestObj => { const target = await message.client.users.fetch(user.discord_id as string) as User; From ccdc0d7c09321cde656d5dd63ad0bd90a40cefef Mon Sep 17 00:00:00 2001 From: theimperious1 Date: Tue, 24 Dec 2024 22:33:07 -0500 Subject: [PATCH 3/4] Mention or UID works now --- src/discord/commands/guild/d.watchuser.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/discord/commands/guild/d.watchuser.ts b/src/discord/commands/guild/d.watchuser.ts index d5db43bbb..3d2e87c17 100644 --- a/src/discord/commands/guild/d.watchuser.ts +++ b/src/discord/commands/guild/d.watchuser.ts @@ -18,8 +18,8 @@ export const dWatchUser: SlashCommand = { .addSubcommand(subcommand => subcommand .setName('add') .setDescription('Set a Watch on a user.') - .addStringOption(option => option.setName('target') - .setDescription('The target user to watch for') + .addUserOption(option => option.setName('target') + .setDescription('The target user to watch for or their Discord ID') .setRequired(true)) .addStringOption(option => option.setName('notification_method') .setDescription('How do you want to be notified?') @@ -52,10 +52,10 @@ export const dWatchUser: SlashCommand = { return false; } - const targetUserId = interaction.options.getString('target', true); + const targetUser = interaction.options.getUser('target', true); if (interaction.options.getSubcommand() === 'cancel') { - if (await deleteWatchRequest(targetUserId, interaction.user.id)) { + if (await deleteWatchRequest(targetUser.id, interaction.user.id)) { await interaction.editReply({ content: 'Done! You won\'t be notified the next time this user is active.' }); return true; } @@ -77,14 +77,14 @@ export const dWatchUser: SlashCommand = { } const notificationMethod = interaction.options.getString('notification_method', true); - const target = await interaction.client.users.fetch(targetUserId); + // const target = await interaction.client.users.fetch(targetUser.id); - if (await executeWatch(target, notificationMethod, interaction.user.id, alertChannel)) { + if (await executeWatch(targetUser, notificationMethod, interaction.user.id, alertChannel)) { await interaction.editReply({ content: 'Done! You\'ll be notified when this user is next seen active.' }); const channelBotlog = await interaction.guild.channels.fetch(env.CHANNEL_BOTLOG) as TextChannel; if (channelBotlog) { - await channelBotlog.send(`${(interaction.member as GuildMember).displayName} used /watch on ${target}`); + await channelBotlog.send(`${(interaction.member as GuildMember).displayName} used /watch on ${targetUser}`); } } else { // eslint-disable-next-line max-len From 3e7c6262a9441b82ab95d2e2eec8ef572cbaf828 Mon Sep 17 00:00:00 2001 From: theimperious1 Date: Tue, 24 Dec 2024 22:45:35 -0500 Subject: [PATCH 4/4] Create watch requests instead of updating them on users.watch_requests column --- src/global/commands/g.watchuser.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/src/global/commands/g.watchuser.ts b/src/global/commands/g.watchuser.ts index c252d25b1..55993fc64 100644 --- a/src/global/commands/g.watchuser.ts +++ b/src/global/commands/g.watchuser.ts @@ -39,19 +39,14 @@ export async function dbAddWatchRequest( // Add new watch requests to the existing user's watch_requests array const latestRequest = watchRequests[watchRequests.length - 1]; - return db.users.update({ - where: { - discord_id: targetUserId, - }, + // We can also do the same thing by adding these directly to watch_requests on the User table. + return db.watch_request.create({ data: { - watch_requests: { - create: { - notification_method: latestRequest.notification_method, - channel_id: latestRequest.channel_id, - caller_id: latestRequest.caller_id, - watched_user_id: latestRequest.watched_user_id, - }, - }, + notification_method: latestRequest.notification_method, + channel_id: latestRequest.channel_id, + caller_id: latestRequest.caller_id, + watched_user_id: latestRequest.watched_user_id, + usersId: existingUser.id, }, }); }