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.