Skip to content

Commit

Permalink
Implement /watch add and /watch cancel to notify when a user is activ…
Browse files Browse the repository at this point in the history
…e somewhere (#917)

* Initial /watch add, cancel implementations

---------

Co-authored-by: theimperious1 <[email protected]>
  • Loading branch information
theimperious1 and theimperious1 authored Dec 25, 2024
1 parent c298154 commit b1d6b90
Show file tree
Hide file tree
Showing 5 changed files with 324 additions and 6 deletions.
98 changes: 98 additions & 0 deletions src/discord/commands/guild/d.watchuser.ts
Original file line number Diff line number Diff line change
@@ -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.')
.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?')
.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 targetUser = interaction.options.getUser('target', true);

if (interaction.options.getSubcommand() === 'cancel') {
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;
}
// 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(targetUser.id);

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 ${targetUser}`);
}
} 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;
2 changes: 2 additions & 0 deletions src/discord/events/messageCreate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -89,6 +90,7 @@ export const messageCreate: MessageCreateEvent = {
karma(message);
imagesOnly(message);
discordAiModerate(message);
nightsWatch(message);

// Disabled for testing
// thoughtPolice(message);
Expand Down
193 changes: 193 additions & 0 deletions src/global/commands/g.watchuser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
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];

// We can also do the same thing by adding these directly to watch_requests on the User table.
return db.watch_request.create({
data: {
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,
},
});
}
// User not found
throw new Error(`User with ID ${targetUserId} not found.`);
}

export async function dbDeleteWatchRequest(targetUserId: string, callerId: string): Promise<void> {
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<boolean> {
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;

if (watchRequests.length === 0) {
return;
}

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<boolean> {
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;
}
Original file line number Diff line number Diff line change
@@ -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;
23 changes: 17 additions & 6 deletions src/prisma/tripbot/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit b1d6b90

Please sign in to comment.