From 844670fa4dcba75c8dd1b031ca70d2181eac5d51 Mon Sep 17 00:00:00 2001 From: dev-737 <73829355+dev-737@users.noreply.github.com> Date: Sat, 22 Feb 2025 17:49:29 +0530 Subject: [PATCH] feat: `/profile` command to view own or someone else's profile (#246) * feat: basic `/profile` command * remove LevelingService from custom client props * add makeshift badge --- prisma/schema.prisma | 7 +- src/commands/Information/rank.ts | 16 +- src/commands/profile.ts | 67 +++++ src/core/BaseClient.ts | 3 - src/services/LevelingService.ts | 416 +++++++++++++++---------------- src/services/UserDbService.ts | 1 + src/types/CustomClientProps.d.ts | 17 +- src/utils/JSON/emojis.json | 4 + src/utils/Leaderboard.ts | 12 + 9 files changed, 309 insertions(+), 234 deletions(-) create mode 100644 src/commands/profile.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ed3c2f87..8f0a6200 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -169,9 +169,6 @@ model UserData { banReason String? mentionOnReply Boolean @default(true) acceptedRules Boolean @default(false) - updatedAt DateTime @updatedAt - xp Int @default(0) - level Int @default(0) messageCount Int @default(0) lastMessageAt DateTime @default(now()) modPositions HubModerator[] @@ -179,9 +176,9 @@ model UserData { infractions Infraction[] @relation("infractions") issuedInfractions Infraction[] @relation("issuedInfractions") inboxLastReadDate DateTime? @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - @@index([xp]) - @@index([level]) @@index([messageCount]) } diff --git a/src/commands/Information/rank.ts b/src/commands/Information/rank.ts index ddb39bfd..0226f938 100644 --- a/src/commands/Information/rank.ts +++ b/src/commands/Information/rank.ts @@ -91,14 +91,14 @@ export default class RankCommand extends BaseCommand { await ctx.deferReply(); try { - const targetUser = await ctx.options.getUser('user') ?? ctx.user; - const stats = await ctx.client.userLevels.getStats( - targetUser.id, - targetUser.username, - ); - const rankCard = await this.createRankCard(targetUser, stats); - - await ctx.editReply({ files: [rankCard] }); + // const targetUser = await ctx.options.getUser('user') ?? ctx.user; + // const stats = await ctx.client.userLevels.getStats( + // targetUser.id, + // targetUser.username, + // ); + // const rankCard = await this.createRankCard(targetUser, stats); + + // await ctx.editReply({ files: [rankCard] }); } catch (error) { handleError(error, { diff --git a/src/commands/profile.ts b/src/commands/profile.ts new file mode 100644 index 00000000..a76a2638 --- /dev/null +++ b/src/commands/profile.ts @@ -0,0 +1,67 @@ +import BaseCommand from '#src/core/BaseCommand.js'; +import Context from '#src/core/CommandContext/Context.js'; +import Constants from '#src/utils/Constants.js'; +import db from '#src/utils/Db.js'; +import { getUserLeaderboardRank } from '#src/utils/Leaderboard.js'; +import { checkIfStaff, fetchUserData } from '#src/utils/Utils.js'; +import { ApplicationCommandOptionType, EmbedBuilder, time } from 'discord.js'; +export default class ProfileCommand extends BaseCommand { + constructor() { + super({ + name: 'profile', + description: 'View your profile or someone else\'s InterChat profile.', + types: { slash: true, prefix: true }, + options: [ + { + type: ApplicationCommandOptionType.User, + name: 'user', + description: 'The user to view the profile of.', + required: false, + }, + ], + }); + } + async execute(ctx: Context) { + const user = (await ctx.options.getUser('user')) ?? ctx.user; + const userData = await fetchUserData(user.id); + + if (!userData) { + await ctx.reply('User not found.'); + return; + } + + const embed = new EmbedBuilder() + .setDescription(`### @${user.username} ${checkIfStaff(user.id) ? ctx.getEmoji('staff_badge') : ''}`) + .addFields([ + { + name: 'Leaderboard Rank', + value: `#${(await getUserLeaderboardRank(user.id)) ?? 'Unranked.'}`, + inline: true, + }, + { + name: 'Total Messages', + value: `${userData.messageCount}`, + inline: true, + }, + { + name: 'User Since', + value: `${time(Math.round(userData.createdAt.getTime() / 1000), 'D')}`, + inline: true, + }, + { + name: 'Hubs Owned', + value: `${(await db.hub.findMany({ where: { ownerId: user.id, private: false } })).map((h) => h.name).join(', ')}`, + inline: true, + }, + { + name: 'User ID', + value: user.id, + inline: true, + }, + ]) + .setColor(Constants.Colors.invisible) + .setThumbnail(user.displayAvatarURL()); + + await ctx.reply({ embeds: [embed] }); + } +} diff --git a/src/core/BaseClient.ts b/src/core/BaseClient.ts index 571265f5..41582643 100644 --- a/src/core/BaseClient.ts +++ b/src/core/BaseClient.ts @@ -20,7 +20,6 @@ import type { InteractionFunction } from '#src/decorators/RegisterInteractionHan import AntiSpamManager from '#src/managers/AntiSpamManager.js'; import EventLoader from '#src/modules/Loaders/EventLoader.js'; import CooldownService from '#src/services/CooldownService.js'; -import { LevelingService } from '#src/services/LevelingService.js'; import Scheduler from '#src/services/SchedulerService.js'; import { loadInteractions } from '#src/utils/CommandUtils.js'; import { loadCommands } from '#src/utils/Loaders.js'; @@ -59,8 +58,6 @@ export default class InterChatClient extends Client { spamCountExpirySecs: 60, }); - public readonly userLevels: LevelingService = new LevelingService(); - constructor() { super({ shards: getInfo().SHARD_LIST, // An array of shards that will get spawned diff --git a/src/services/LevelingService.ts b/src/services/LevelingService.ts index 16e0c72a..d4ec0e85 100644 --- a/src/services/LevelingService.ts +++ b/src/services/LevelingService.ts @@ -1,208 +1,208 @@ -/* - * Copyright (C) 2025 InterChat - * - * InterChat is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * InterChat is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with InterChat. If not, see . - */ - -import UserDbService from '#src/services/UserDbService.js'; -import db from '#src/utils/Db.js'; -import { calculateRequiredXP } from '#src/utils/calculateLevel.js'; -import type { PrismaClient, UserData } from '@prisma/client'; -import { Colors, type Message } from 'discord.js'; - -type LeaderboardType = 'xp' | 'level' | 'messages'; - -interface UserStats { - xp: { rank: number }; - level: { rank: number }; - messages: { rank: number }; -} - -interface LevelingConfig { - xpRange: { min: number; max: number }; - cooldownSeconds: number; -} - -export class LevelingService { - private readonly db: PrismaClient; - private readonly userCooldowns: Map; - private readonly config: LevelingConfig; - private readonly userService = new UserDbService(); - - constructor(prisma?: PrismaClient, config: Partial = {}) { - this.db = prisma ?? db; - this.userCooldowns = new Map(); - this.config = { - xpRange: { min: 3, max: 8 }, - cooldownSeconds: 5, - ...config, - }; - } - - public async handleMessage(message: Message): Promise { - if (!this.isValidMessage(message)) return; - - const userId = message.author.id; - if (this.isUserOnCooldown(userId)) return; - - await this.processMessageXP(message); - this.updateUserCooldown(userId); - } - - public async getStats(userId: string, username: string): Promise< - UserData & { - stats: UserStats; - requiredXP: number; - } - > { - const user = await this.getOrCreateUser(userId, username); - const stats = await this.calculateUserStats(user); - - return { - ...user, - stats, - requiredXP: calculateRequiredXP(user.level), - }; - } - - public async getLeaderboard(type: LeaderboardType = 'xp', limit = 10): Promise { - const orderBy = this.getLeaderboardOrdering(type); - - return await this.db.userData.findMany({ orderBy, take: limit }); - } - - private isValidMessage(message: Message): boolean { - return !message.author.bot; - } - - private isUserOnCooldown(userId: string): boolean { - const lastMessage = this.userCooldowns.get(userId); - if (!lastMessage) return false; - - const cooldownMs = this.config.cooldownSeconds * 1000; - return Date.now() - lastMessage.getTime() < cooldownMs; - } - - private async processMessageXP(message: Message): Promise { - const user = await this.getOrCreateUser(message.author.id, message.author.username); - const earnedXP = this.generateXP(); - const { newLevel, totalXP } = this.calculateXPAndLevel(user, earnedXP); - - if (newLevel > user.level) { - await this.handleLevelUp(message, newLevel); - } - - await this.updateUserData(user.id, { - xp: totalXP, - level: newLevel, - messageCount: user.messageCount + 1, - }); - } - - private generateXP(): number { - const { min, max } = this.config.xpRange; - return Math.floor(Math.random() * (max - min + 1)) + min; - } - - private calculateXPAndLevel( - user: UserData, - earnedXP: number, - ): { - newLevel: number; - newXP: number; - totalXP: number; - } { - const requiredXP = calculateRequiredXP(user.level); - const currentLevelXP = user.xp % requiredXP; - const totalXP = user.xp + earnedXP; - let newXP = currentLevelXP + earnedXP; - let newLevel = user.level; - - if (newXP >= requiredXP) { - newLevel = user.level + 1; - newXP -= requiredXP; - } - - return { newLevel, newXP, totalXP }; - } - - - private async handleLevelUp(message: Message, newLevel: number): Promise { - // TODO: also send a random tip along with the level up message - await message.channel.send({ - embeds: [ - { - title: '🎉 Level Up!', - description: `Congratulations ${message.author}! You've reached level ${newLevel}!`, - footer: { - text: `Sent for: ${message.author.username}`, - icon_url: message.author.displayAvatarURL(), - }, - color: Colors.Green, - }, - ], - }).catch(() => null); - } - - private async getOrCreateUser(userId: string, username: string): Promise { - const user = await this.userService.getUser(userId); - - return user ?? (await this.userService.createUser({ id: userId, username })); - } - - private async calculateUserStats(user: UserData): Promise { - return { - xp: { - rank: await this.calculateRank('xp', user.xp), - }, - level: { - rank: await this.calculateRank('level', user.level), - }, - messages: { - rank: await this.calculateRank('messageCount', user.messageCount), - }, - }; - } - - private async calculateRank( - field: 'xp' | 'level' | 'messageCount', - value: number, - ): Promise { - return ( - (await this.db.userData.count({ - where: { - [field]: { gt: value }, - }, - })) + 1 - ); - } - - private async updateUserData(userId: string, data: Partial): Promise { - await this.userService.updateUser(userId, { ...data, lastMessageAt: new Date() }); - } - - private updateUserCooldown(userId: string): void { - this.userCooldowns.set(userId, new Date()); - } - - private getLeaderboardOrdering(type: LeaderboardType) { - const orderByMap = { - xp: { xp: 'desc' as const }, - level: { level: 'desc' as const }, - messages: { messageCount: 'desc' as const }, - }; - - return orderByMap[type]; - } -} +// /* +// * Copyright (C) 2025 InterChat +// * +// * InterChat is free software: you can redistribute it and/or modify +// * it under the terms of the GNU Affero General Public License as published +// * by the Free Software Foundation, either version 3 of the License, or +// * (at your option) any later version. +// * +// * InterChat is distributed in the hope that it will be useful, +// * but WITHOUT ANY WARRANTY; without even the implied warranty of +// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// * GNU Affero General Public License for more details. +// * +// * You should have received a copy of the GNU Affero General Public License +// * along with InterChat. If not, see . +// */ + +// import UserDbService from '#src/services/UserDbService.js'; +// import db from '#src/utils/Db.js'; +// import { calculateRequiredXP } from '#src/utils/calculateLevel.js'; +// import type { PrismaClient, UserData } from '@prisma/client'; +// import { Colors, type Message } from 'discord.js'; + +// type LeaderboardType = 'xp' | 'level' | 'messages'; + +// interface UserStats { +// xp: { rank: number }; +// level: { rank: number }; +// messages: { rank: number }; +// } + +// interface LevelingConfig { +// xpRange: { min: number; max: number }; +// cooldownSeconds: number; +// } + +// export class LevelingService { +// private readonly db: PrismaClient; +// private readonly userCooldowns: Map; +// private readonly config: LevelingConfig; +// private readonly userService = new UserDbService(); + +// constructor(prisma?: PrismaClient, config: Partial = {}) { +// this.db = prisma ?? db; +// this.userCooldowns = new Map(); +// this.config = { +// xpRange: { min: 3, max: 8 }, +// cooldownSeconds: 5, +// ...config, +// }; +// } + +// public async handleMessage(message: Message): Promise { +// if (!this.isValidMessage(message)) return; + +// const userId = message.author.id; +// if (this.isUserOnCooldown(userId)) return; + +// await this.processMessageXP(message); +// this.updateUserCooldown(userId); +// } + +// public async getStats(userId: string, username: string): Promise< +// UserData & { +// stats: UserStats; +// requiredXP: number; +// } +// > { +// const user = await this.getOrCreateUser(userId, username); +// const stats = await this.calculateUserStats(user); + +// return { +// ...user, +// stats, +// requiredXP: calculateRequiredXP(user.level), +// }; +// } + +// public async getLeaderboard(type: LeaderboardType = 'xp', limit = 10): Promise { +// const orderBy = this.getLeaderboardOrdering(type); + +// return await this.db.userData.findMany({ orderBy, take: limit }); +// } + +// private isValidMessage(message: Message): boolean { +// return !message.author.bot; +// } + +// private isUserOnCooldown(userId: string): boolean { +// const lastMessage = this.userCooldowns.get(userId); +// if (!lastMessage) return false; + +// const cooldownMs = this.config.cooldownSeconds * 1000; +// return Date.now() - lastMessage.getTime() < cooldownMs; +// } + +// private async processMessageXP(message: Message): Promise { +// const user = await this.getOrCreateUser(message.author.id, message.author.username); +// const earnedXP = this.generateXP(); +// const { newLevel, totalXP } = this.calculateXPAndLevel(user, earnedXP); + +// if (newLevel > user.level) { +// await this.handleLevelUp(message, newLevel); +// } + +// await this.updateUserData(user.id, { +// xp: totalXP, +// level: newLevel, +// messageCount: user.messageCount + 1, +// }); +// } + +// private generateXP(): number { +// const { min, max } = this.config.xpRange; +// return Math.floor(Math.random() * (max - min + 1)) + min; +// } + +// private calculateXPAndLevel( +// user: UserData, +// earnedXP: number, +// ): { +// newLevel: number; +// newXP: number; +// totalXP: number; +// } { +// const requiredXP = calculateRequiredXP(user.level); +// const currentLevelXP = user.xp % requiredXP; +// const totalXP = user.xp + earnedXP; +// let newXP = currentLevelXP + earnedXP; +// let newLevel = user.level; + +// if (newXP >= requiredXP) { +// newLevel = user.level + 1; +// newXP -= requiredXP; +// } + +// return { newLevel, newXP, totalXP }; +// } + + +// private async handleLevelUp(message: Message, newLevel: number): Promise { +// // TODO: also send a random tip along with the level up message +// await message.channel.send({ +// embeds: [ +// { +// title: '🎉 Level Up!', +// description: `Congratulations ${message.author}! You've reached level ${newLevel}!`, +// footer: { +// text: `Sent for: ${message.author.username}`, +// icon_url: message.author.displayAvatarURL(), +// }, +// color: Colors.Green, +// }, +// ], +// }).catch(() => null); +// } + +// private async getOrCreateUser(userId: string, username: string): Promise { +// const user = await this.userService.getUser(userId); + +// return user ?? (await this.userService.createUser({ id: userId, username })); +// } + +// private async calculateUserStats(user: UserData): Promise { +// return { +// xp: { +// rank: await this.calculateRank('xp', user.xp), +// }, +// level: { +// rank: await this.calculateRank('level', user.level), +// }, +// messages: { +// rank: await this.calculateRank('messageCount', user.messageCount), +// }, +// }; +// } + +// private async calculateRank( +// field: 'xp' | 'level' | 'messageCount', +// value: number, +// ): Promise { +// return ( +// (await this.db.userData.count({ +// where: { +// [field]: { gt: value }, +// }, +// })) + 1 +// ); +// } + +// private async updateUserData(userId: string, data: Partial): Promise { +// await this.userService.updateUser(userId, { ...data, lastMessageAt: new Date() }); +// } + +// private updateUserCooldown(userId: string): void { +// this.userCooldowns.set(userId, new Date()); +// } + +// private getLeaderboardOrdering(type: LeaderboardType) { +// const orderByMap = { +// xp: { xp: 'desc' as const }, +// level: { level: 'desc' as const }, +// messages: { messageCount: 'desc' as const }, +// }; + +// return orderByMap[type]; +// } +// } diff --git a/src/services/UserDbService.ts b/src/services/UserDbService.ts index 19d712f9..da14a76c 100644 --- a/src/services/UserDbService.ts +++ b/src/services/UserDbService.ts @@ -37,6 +37,7 @@ export default class UserDbService { updatedAt: new Date(user.updatedAt), lastVoted: user.lastVoted ? new Date(user.lastVoted) : null, inboxLastReadDate: new Date(user.inboxLastReadDate ?? 0), + createdAt: new Date(user.createdAt), }; return { ...user, ...dates }; } diff --git a/src/types/CustomClientProps.d.ts b/src/types/CustomClientProps.d.ts index 6d7e8bc6..cac71a08 100644 --- a/src/types/CustomClientProps.d.ts +++ b/src/types/CustomClientProps.d.ts @@ -15,6 +15,13 @@ * along with InterChat. If not, see . */ +import type BaseCommand from '#src/core/BaseCommand.js'; +import type BasePrefixCommand from '#src/core/BasePrefixCommand.js'; +import type { InteractionFunction } from '#src/decorators/RegisterInteractionHandler.js'; +import type AntiSpamManager from '#src/managers/AntiSpamManager.js'; +import type EventLoader from '#src/modules/Loaders/EventLoader.js'; +import type CooldownService from '#src/services/CooldownService.js'; +import type Scheduler from '#src/services/SchedulerService.js'; import type { ClusterClient } from 'discord-hybrid-sharding'; import type { Collection, @@ -24,14 +31,6 @@ import type { Snowflake, TextChannel, } from 'discord.js'; -import type BaseCommand from '#src/core/BaseCommand.js'; -import type BasePrefixCommand from '#src/core/BasePrefixCommand.js'; -import type { InteractionFunction } from '#src/decorators/RegisterInteractionHandler.js'; -import type AntiSpamManager from '#src/managers/AntiSpamManager.js'; -import type EventLoader from '#src/modules/Loaders/EventLoader.js'; -import type CooldownService from '#src/services/CooldownService.js'; -import type Scheduler from '#src/services/SchedulerService.js'; -import { LevelingService } from '#src/services/LevelingService.js'; export type RemoveMethods = { [K in keyof T]: T[K] extends (...args: unknown[]) => unknown ? never : RemoveMethods; @@ -54,8 +53,6 @@ declare module 'discord.js' { readonly cluster: ClusterClient; readonly antiSpamManager: AntiSpamManager; - readonly userLevels: LevelingService; - fetchGuild(guildId: Snowflake): Promise | undefined>; getScheduler(): Scheduler; } diff --git a/src/utils/JSON/emojis.json b/src/utils/JSON/emojis.json index ba9736ff..815c7c68 100644 --- a/src/utils/JSON/emojis.json +++ b/src/utils/JSON/emojis.json @@ -530,5 +530,9 @@ "zap_icon": { "url": "https://cdn.discordapp.com/emojis/1338726325682311200.png", "updatedAt": "2025-02-11T04:20:28.375Z" + }, + "staff_badge": { + "url": "https://cdn.discordapp.com/emojis/1342718965817802764.png", + "updatedAt": "2025-02-22T04:45:47.963Z" } } \ No newline at end of file diff --git a/src/utils/Leaderboard.ts b/src/utils/Leaderboard.ts index 00b4dcc8..c715913b 100644 --- a/src/utils/Leaderboard.ts +++ b/src/utils/Leaderboard.ts @@ -54,6 +54,18 @@ export async function getLeaderboard(type: 'user' | 'server', limit = 10): Promi return results; } + +/** + * Retrieves the leaderboard rank for a given user. + * Redis' zrevrank returns a 0-indexed rank, so we add 1. + */ +export async function getUserLeaderboardRank(userId: string): Promise { + const redis = getRedis(); + const leaderboardKey = getLeaderboardKey('leaderboard:messages:users'); + const rank = await redis.zrevrank(leaderboardKey, userId); + return rank !== null ? rank + 1 : null; +} + /** * Returns the display value for a given rank. * Adds medal icons for the top three ranks.