Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement /watch add and /watch cancel to notify when a user is active somewhere #917

Merged
merged 4 commits into from
Dec 25, 2024
Merged
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
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
Loading