diff --git a/src/Config.prod.ts b/src/Config.prod.ts index 9f0a0a5f..39d6d0bb 100644 --- a/src/Config.prod.ts +++ b/src/Config.prod.ts @@ -22,6 +22,7 @@ export const config: Config = { commands: { daily: "1059214166075912225", }, + roles: { tiers: [ "821743100203368458", // @everyone (tier 0) diff --git a/src/Config.ts b/src/Config.ts index 28bc5e7a..023fd561 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -12,6 +12,7 @@ export const config: Config = { auditLog: "994623474557538415", modLog: "994623474557538415", general: "904478147351806015", + leaderboard: "904478147351806015", }, starboard: { emojiId: "⭐", diff --git a/src/config.type.ts b/src/config.type.ts index 43b4b7d4..4cf5c1b7 100644 --- a/src/config.type.ts +++ b/src/config.type.ts @@ -20,6 +20,7 @@ export interface Config { modLog: string; introductions?: string; general: string; + leaderboard?: string; }; starboard: { emojiId: string; diff --git a/src/index.ts b/src/index.ts index dc650954..e5153ed0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import ImageForwarderModule from "./modules/imageForwarder.module.js"; import { InformationModule } from "./modules/information/information.module.js"; import JoinLeaveMessageModule from "./modules/joinLeaveMessage.module.js"; import { LanguageStatusModule } from "./modules/languageStatus.module.js"; +import LeaderboardModule from "./modules/leaderboard/leaderboard.module.js"; import { LearningModule } from "./modules/learning/learning.module.js"; import { ModerationModule } from "./modules/moderation/moderation.module.js"; import { ModmailModule } from "./modules/modmail/modmail.module.js"; @@ -65,6 +66,7 @@ export const moduleManager = new ModuleManager( ModerationModule, StarboardModule, ModmailModule, + LeaderboardModule, ], ); diff --git a/src/modules/core/bump.listener.test.ts b/src/modules/core/bump.listener.test.ts index ebd8ad40..67f0822d 100644 --- a/src/modules/core/bump.listener.test.ts +++ b/src/modules/core/bump.listener.test.ts @@ -3,6 +3,7 @@ import type { Client, Message, MessageInteraction, + MessageInteractionMetadata, PartialTextBasedChannelFields, User, } from "discord.js"; @@ -67,7 +68,7 @@ test("simple bump", async () => { const { ddUser, fakeUser, mockReact, mockChannel } = await setupMocks(); await handleBumpStreak( ddUser, - { user: fakeUser } as unknown as MessageInteraction, + { user: fakeUser } as unknown as MessageInteractionMetadata, { channel: mockChannel, react: mockReact } as unknown as Message & { channel: PartialTextBasedChannelFields; }, @@ -88,7 +89,7 @@ test("simple bump with streak", async () => { await handleBumpStreak( ddUser, - { user: fakeUser } as unknown as MessageInteraction, + { user: fakeUser } as unknown as MessageInteractionMetadata, { channel: mockChannel, react: mockReact } as unknown as Message & { channel: PartialTextBasedChannelFields; }, @@ -113,7 +114,7 @@ test("simple bump with big streak", async () => { } await handleBumpStreak( ddUser, - { user: fakeUser } as unknown as MessageInteraction, + { user: fakeUser } as unknown as MessageInteractionMetadata, { channel: mockChannel, react: mockReact } as unknown as Message & { channel: PartialTextBasedChannelFields; }, @@ -137,7 +138,7 @@ test("speedy bump ", async () => { setLastBumpNotificationTime(new Date(Date.now() - 1000)); await handleBumpStreak( ddUser, - { user: fakeUser } as unknown as MessageInteraction, + { user: fakeUser } as unknown as MessageInteractionMetadata, { channel: mockChannel, react: mockReact } as unknown as Message & { channel: PartialTextBasedChannelFields; }, @@ -170,7 +171,9 @@ test("End other user's streak", async () => { }); await handleBumpStreak( otherUser, - { user: createFakeUser(otherUserId) } as unknown as MessageInteraction, + { + user: createFakeUser(otherUserId), + } as unknown as MessageInteractionMetadata, { channel: mockChannel, react: mockReact } as unknown as Message & { channel: PartialTextBasedChannelFields; }, @@ -243,7 +246,9 @@ test("End other user's streak with real data", async () => { await handleBumpStreak( otherUser, - { user: createFakeUser(otherUserId) } as unknown as MessageInteraction, + { + user: createFakeUser(otherUserId), + } as unknown as MessageInteractionMetadata, { channel: mockChannel, react: mockReact } as unknown as Message & { channel: PartialTextBasedChannelFields; }, diff --git a/src/modules/core/bump.listener.ts b/src/modules/core/bump.listener.ts index 113b4c2d..acdb1a3d 100644 --- a/src/modules/core/bump.listener.ts +++ b/src/modules/core/bump.listener.ts @@ -4,7 +4,7 @@ import { type EmojiIdentifierResolvable, InteractionType, type Message, - type MessageInteraction, + type MessageInteractionMetadata, type PartialTextBasedChannelFields, } from "discord.js"; import { config } from "../../Config.js"; @@ -16,6 +16,7 @@ import { getAllBumps, getBumpStreak, getStreaks, + type Streak, } from "../../store/models/bumps.js"; import { type DDUser, getOrCreateUserById } from "../../store/models/DDUser.js"; import { fakeMention, mentionIfPingable } from "../../util/users.js"; @@ -42,10 +43,9 @@ export function setLastBumpNotificationTime(date: Date) { lastBumpNotificationTime = date; } -// noinspection JSDeprecatedSymbols export async function handleBumpStreak( bumper: DDUser, - interactionOld: MessageInteraction, + interactionOld: MessageInteractionMetadata, message: Message & { channel: PartialTextBasedChannelFields; }, @@ -69,15 +69,14 @@ export async function handleBumpStreak( streak.current === 1 // just started a new streak ) { // allStreaks[-1] will be the current streak - const mostRecent = allStreaks[allStreaks.length - 2]; // so check the one before that + const mostRecent = allStreaks.at(-2) as { + userId: bigint; + } & Streak; // so check the one before that logger.debug(`Most recent streak:`, mostRecent); - logger.debug( - "Most recent streaks:", - allStreaks.slice(allStreaks.length - 5), - ); + logger.debug("Most recent streaks:", allStreaks.slice(-5)); if (mostRecent.userId !== bumper.id && mostRecent.current > 2) { const user = await client.users.fetch(mostRecent.userId.toString()); - message.channel.send( + await message.channel.send( `☠️ ${mentionIfPingable(interactionOld.user)} ended ${fakeMention(user)}'s bump streak of ${mostRecent.current}!`, ); } @@ -88,7 +87,7 @@ export async function handleBumpStreak( const timeSinceLastBump = Date.now() - lastBumpNotificationTime.getTime(); if (timeSinceLastBump < 30000) { // this might seem generous, but in reality when you factor in the discord delay, even if you react instantaneously on your screen you can still be too slow - message.channel.send( + await message.channel.send( `⚡⚡⚡ ${fakeMention(interactionOld.user)} bumped in just **${timeSinceLastBump / 1000}s**!`, ); } else { @@ -104,12 +103,14 @@ export async function handleBumpStreak( if (streak.current === streak.highest) { // new high score! - message.channel.send( + await message.channel.send( `${mentionIfPingable(interactionOld.user)}, you beat your max bump streak and are now on a streak of ${streak.current}! Keep it up!`, ); } - const highestStreakEver = allStreaks.sort((a, b) => b.highest - a.highest)[0]; + const highestStreakEver = allStreaks.toSorted( + (a, b) => b.highest - a.highest, + )[0]; logger.debug("Highest streak ever: %O", highestStreakEver); logger.debug("This streak: %O", streak); if ( @@ -121,17 +122,16 @@ export async function handleBumpStreak( ) { // if they currently have the highest streak logger.debug("User has the highest streak"); - message.channel.send( + await message.channel.send( `🔥🔥🔥🔥🔥 ${mentionIfPingable(interactionOld.user)}, you have the highest EVER bump streak in the server of ${highestStreakEver.highest}! Keep it up!`, ); } } -// noinspection JSDeprecatedSymbols export async function handleBump( client: Client, bumper: DDUser, - interactionOld: MessageInteraction, + interactionOld: MessageInteractionMetadata, message: Message & { channel: PartialTextBasedChannelFields; }, @@ -144,37 +144,34 @@ export async function handleBump( } export const BumpListener: EventListener = { - ready: async (client) => { + clientReady: async (client) => { scheduleBumpReminder(client); }, messageCreate: async (client, message) => { const interaction = message.interactionMetadata; - if ( - !interaction || - !(interaction.type === InteractionType.ApplicationCommand) - ) + if (!interaction || interaction.type !== InteractionType.ApplicationCommand) return; if (message.author.id !== "302050872383242240") return; // /disboard user id - // noinspection JSDeprecatedSymbols don't think there's another way of doing this + + // noinspection JSDeprecatedSymbols don't think there's another way of doing this ( yes there is ) const interactionOld = message.interaction; if (interactionOld?.commandName !== "bump") return; - // since the bump failed message is ephemeral, we know if we can see the message then the bump succeeded! - const ddUser = await getOrCreateUserById(BigInt(interactionOld.user.id)); + const ddUser = await getOrCreateUserById(BigInt(interaction.user.id)); // Bump await Bump.create({ messageId: BigInt(message.id), - userId: BigInt(interactionOld.user.id), + userId: BigInt(interaction.user.id), timestamp: new Date(), }); logger.info( - `User ${interactionOld.user.id} bumped! Total bumps: ${await ddUser.countBumps()}`, + `User ${interaction.user.id} bumped! Total bumps: ${await ddUser.countBumps()}`, ); clearBumpsCache(); await ddUser.save(); - await handleBump(client, ddUser, interactionOld, message); + await handleBump(client, ddUser, interaction, message); }, }; const streakReacts: EmojiIdentifierResolvable[] = [ diff --git a/src/modules/xp/leaderboard.command.ts b/src/modules/leaderboard/leaderboard.command.ts similarity index 51% rename from src/modules/xp/leaderboard.command.ts rename to src/modules/leaderboard/leaderboard.command.ts index 3e518432..03cba8e7 100644 --- a/src/modules/xp/leaderboard.command.ts +++ b/src/modules/leaderboard/leaderboard.command.ts @@ -1,3 +1,4 @@ +import { Op, Sequelize, sql } from "@sequelize/core"; import type { APIApplicationCommandOptionChoice, GuildMember, @@ -12,7 +13,15 @@ import { DDUser } from "../../store/models/DDUser.js"; import { branding } from "../../util/branding.js"; import { createStandardEmbed } from "../../util/embeds.js"; import { actualMention } from "../../util/users.js"; -import { getActualDailyStreak } from "./dailyReward.command.js"; +import { getActualDailyStreak } from "../xp/dailyReward.command.js"; + +import { getUserAndBumpsAggregated, medal } from "./leaderboard.js"; + +// For Bump Leaderboard +interface MappedUser { + userId: string; + count: number; +} type KeysMatching = { [K in keyof T]-?: T[K] extends V ? K : never; @@ -25,7 +34,12 @@ type LeaderboardSelection = interface LeaderboardType extends APIApplicationCommandOptionChoice { select: LeaderboardSelection; value: string; + sortingValue?: keyof DDUser; + filter?: (user: DDUser, count: number) => Promise; format: (value: number | bigint) => string; + // The select function is a placeholder when useAggregation is true and is not used when useAggregation is true. + // The actual count comes from aggregation in getAggregatedLeaderboardUsers. + useAggregation?: boolean; } const info: LeaderboardType[] = [ @@ -56,12 +70,94 @@ const info: LeaderboardType[] = [ { name: "Disboard Bumps", value: "bumps", + useAggregation: true, select: async (user) => user.countBumps(), format: (value) => - value.toString() === "1" ? "1 Bump" : `${value.toLocaleString()} Bumps`, + value.toString() === "1" ? "1 Bump" : `${value.toString()} Bumps`, + }, + { + name: "Disboard Weekly Bumps", + value: "weeklyBumps", + useAggregation: true, + select: async () => 0, + format: (value) => + value.toString() === "1" ? "1 Bump" : `${value.toString()} Bumps`, }, ]; +async function getAggregatedLeaderboardUsers( + traitInfo: LeaderboardType, +): Promise { + const { value } = traitInfo; + const weekCutoff = + value === "weeklyBumps" + ? new Date(Date.now() - 1000 * 60 * 60 * 24 * 7) + : null; + + const aggregatedUsers = await getUserAndBumpsAggregated(weekCutoff); + const mappedUsers: MappedUser[] = []; + + for (const userData of aggregatedUsers) { + const userId = + typeof userData.id === "string" ? BigInt(userData.id) : userData.id; + const user = await DDUser.findOne({ + where: { + id: userId, + }, + }); + if (!user) continue; + + const count = Number(userData.bumpsCount); + if (count === 0) continue; + + mappedUsers.push({ + userId: user.id.toString(), + count, + }); + } + + return mappedUsers; +} + +async function getStandardLeaderboardUsers( + traitInfo: LeaderboardType, +): Promise { + const { select, sortingValue, value } = traitInfo; + const calculate: (user: DDUser) => Promise = + select instanceof Function ? select : async (user: DDUser) => user[select]; + + const users = await DDUser.findAll({ + order: [[sortingValue ?? value, "DESC"]], + limit: 10, + }); + + const mappedUsers: MappedUser[] = []; + for (const userToBeMapped of users) { + const count = Number(await calculate(userToBeMapped)); + if (count === 0) { + continue; + } + mappedUsers.push({ + userId: userToBeMapped.id.toString(), + count: count, + }); + } + + return mappedUsers; +} + +async function getLeaderboardUsers( + traitInfo: LeaderboardType, +): Promise { + const { value, useAggregation } = traitInfo; + // handle if the user is trying to get bumps or weekly bumps + if (useAggregation && (value === "bumps" || value === "weeklyBumps")) { + return await getAggregatedLeaderboardUsers(traitInfo); + } else { + return await getStandardLeaderboardUsers(traitInfo); + } +} + export const LeaderboardCommand: Command = { type: ApplicationCommandType.ChatInput, name: "leaderboard", @@ -94,35 +190,26 @@ export const LeaderboardCommand: Command = { const all = await DDUser.findAll(); await Promise.all(all.map(getActualDailyStreak)); } - const { format, value, name } = traitInfo; - - const select = traitInfo.select; - const calculate: (user: DDUser) => Promise = - select instanceof Function - ? select - : async (user: DDUser) => user[select]; + const { format, name } = traitInfo; - const users = await DDUser.findAll({ - order: [[value, "DESC"]], - limit: 10, - }).then((users) => users.filter(async (it) => (await calculate(it)) > 0)); + const mappedUsers = await getLeaderboardUsers(traitInfo); - if (users.length === 0) { + if (mappedUsers.length === 0) { await interaction.followUp("No applicable users"); return; } const embed = { ...createStandardEmbed(interaction.member as GuildMember), title: `${branding.name} Leaderboard`, - description: `The top ${users.length} users based on ${name}`, + description: `The top ${mappedUsers.length} users based on ${name}`, fields: await Promise.all( - users.map(async (user, index) => { + mappedUsers.map(async (userData, index) => { const discordUser = await guild.client.users - .fetch(user.id.toString()) + .fetch(userData.userId) .catch(() => null); return { - name: `${medal(index)} #${index + 1} - ${format(await calculate(user))}`.trimStart(), + name: `${medal(index)} #${index + 1} - ${format(userData.count)}`.trimStart(), value: discordUser == null ? "Unknown User" : actualMention(discordUser), }; @@ -133,19 +220,6 @@ export const LeaderboardCommand: Command = { }), }; -function medal(index: number): string { - switch (index) { - case 0: - return "🥇"; - case 1: - return "🥈"; - case 2: - return "🥉"; - default: - return ""; - } -} - function formatDays(days: number | bigint) { if (days === 1) { return "1 day"; diff --git a/src/modules/leaderboard/leaderboard.listener.ts b/src/modules/leaderboard/leaderboard.listener.ts new file mode 100644 index 00000000..029dd355 --- /dev/null +++ b/src/modules/leaderboard/leaderboard.listener.ts @@ -0,0 +1,76 @@ +import { EmbedBuilder, type Guild } from "discord.js"; +import * as schedule from "node-schedule"; +import { config } from "../../Config.js"; +import { logger } from "../../logging.js"; +import { actualMention } from "../../util/users.js"; +import type { EventListener } from "../module.js"; +import { getUserAndBumpsAggregated, medal } from "./leaderboard.js"; + +async function postLeaderboard(guild: Guild) { + if (config.channels.leaderboard == null) { + logger.info( + "Leaderboard channel not configured, skipping leaderboard post.", + ); + return; + } + const announcementChannel = await guild.channels.fetch( + config.channels.leaderboard, + ); + if (announcementChannel == null) { + logger.info("Leaderboard channel not found, skipping leaderboard post."); + return; + } + if (!announcementChannel.isTextBased() || !announcementChannel.isSendable()) { + logger.info( + "Leaderboard channel is not a text channel or not sendable, skipping leaderboard post.", + ); + return; + } + const weekCutoff = new Date(Date.now() - 1000 * 60 * 60 * 24 * 7); + const bumpLeaderboard = ( + await getUserAndBumpsAggregated(weekCutoff, 3) + ).filter((userData) => userData.bumpsCount > 0); + + if (bumpLeaderboard.length === 0) { + logger.info("No users bumped in the last week, skipping leaderboard post."); + return; + } + + const embed = new EmbedBuilder() + .setTitle("Weekly Bump Leaderboard") + .setColor("Gold"); + + const fields = await Promise.all( + bumpLeaderboard.map(async (userData, index) => { + const discordUser = await guild.client.users + .fetch(userData.id.toString()) + .catch(() => null); + + return { + name: `${medal(index)} #${index + 1} - ${userData.bumpsCount} ${userData.bumpsCount > 1 ? "Bumps" : "Bump"}`.trimStart(), + value: + discordUser == null ? "Unknown User" : actualMention(discordUser), + }; + }), + ); + + embed.addFields(fields); + + await announcementChannel.send({ embeds: [embed] }); +} + +export const LeaderboardListener: EventListener = { + clientReady: async (client) => { + const guild = await client.guilds.fetch(config.guildId); + schedule.scheduleJob( + { + hour: 6, + minute: 0, + dayOfWeek: 1, // 0 = Sunday, 1 = Monday, etc. + }, + async () => { + await postLeaderboard(guild); + }, + ); + }, +}; diff --git a/src/modules/leaderboard/leaderboard.module.ts b/src/modules/leaderboard/leaderboard.module.ts new file mode 100644 index 00000000..435a7ae2 --- /dev/null +++ b/src/modules/leaderboard/leaderboard.module.ts @@ -0,0 +1,11 @@ +import type Module from "../module.js"; +import { LeaderboardCommand } from "./leaderboard.command.js"; +import { LeaderboardListener } from "./leaderboard.listener.js"; + +export const LeaderboardModule: Module = { + name: "leaderboard", + commands: [LeaderboardCommand], + listeners: [LeaderboardListener], +}; + +export default LeaderboardModule; diff --git a/src/modules/leaderboard/leaderboard.ts b/src/modules/leaderboard/leaderboard.ts new file mode 100644 index 00000000..e8f96608 --- /dev/null +++ b/src/modules/leaderboard/leaderboard.ts @@ -0,0 +1,62 @@ +import { sql } from "@sequelize/core"; +import { DDUser } from "../../store/models/DDUser.js"; + +export function getUserAndBumpsAggregated( + after?: Date | null, + limit: number = 10, +): Promise< + Array<{ + id: string | bigint; + bumpsCount: number; + }> +> { + // Build the timestamp filter SQL + const timestampFilter = after + ? sql`AND "Bumps"."timestamp" >= + ${after}` + : sql``; + + // Only add historical bumps if we're not filtering by date (i.e., showing all-time bumps) + const historicalBumps = after + ? sql`0` + : sql`"DDUser" + . + "bumps"`; + + return DDUser.findAll({ + attributes: [ + "id", + [ + sql`(SELECT COALESCE(COUNT(*), 0) + ${historicalBumps} + FROM "Bumps" + WHERE "Bumps"."userId" = "DDUser"."id" + ${timestampFilter})`, + "bumpsCount", + ], + ], + order: [ + [ + sql`(SELECT COALESCE(COUNT(*), 0) + ${historicalBumps} + FROM "Bumps" + WHERE "Bumps"."userId" = "DDUser"."id" + ${timestampFilter})`, + "DESC", + ], + ], + limit: limit, + raw: true, + }); +} + +export function medal(index: number): string { + switch (index) { + case 0: + return "🥇"; + case 1: + return "🥈"; + case 2: + return "🥉"; + default: + return ""; + } +} diff --git a/src/modules/xp/xp.module.ts b/src/modules/xp/xp.module.ts index ae22d88a..7acf48aa 100644 --- a/src/modules/xp/xp.module.ts +++ b/src/modules/xp/xp.module.ts @@ -2,14 +2,13 @@ import type Module from "../module.js"; import { DailyRewardCommand } from "./dailyReward.command.js"; import { scheduleAllReminders } from "./dailyReward.reminder.js"; import { RoleJoinListener } from "./join.listener.js"; -import { LeaderboardCommand } from "./leaderboard.command.js"; import { ThreadListener } from "./threadStuff.listener.js"; import { XpCommand } from "./xp.command.js"; import { XpListener } from "./xp.listener.js"; export const XpModule: Module = { name: "xp", - commands: [XpCommand, DailyRewardCommand, LeaderboardCommand], + commands: [XpCommand, DailyRewardCommand], listeners: [XpListener, ThreadListener, RoleJoinListener], onInit: async (_, client) => { await scheduleAllReminders(client); diff --git a/src/store/models/bumps.ts b/src/store/models/bumps.ts index d2c9adf9..2411410c 100644 --- a/src/store/models/bumps.ts +++ b/src/store/models/bumps.ts @@ -4,7 +4,6 @@ import { Bump } from "./Bump.js"; import type { DDUser } from "./DDUser.js"; /** - * @returns All bumps in ascending order of timestamp. */ export const getAllBumps = async (): Promise => @@ -103,13 +102,14 @@ export function extractStreaks( const streaks: T[][] = []; for (const bump of bumps) { + const lastStreak = streaks.at(-1); if ( streaks.length === 0 || - streaks[streaks.length - 1][0].userId !== bump.userId + (lastStreak && lastStreak[0].userId !== bump.userId) ) { streaks.push([bump]); } else { - streaks[streaks.length - 1].push(bump); + lastStreak?.push(bump); } } return streaks; diff --git a/src/store/storage.ts b/src/store/storage.ts index 929980d7..dfc87b41 100644 --- a/src/store/storage.ts +++ b/src/store/storage.ts @@ -82,6 +82,10 @@ export async function initStorage() { as: "user", }); + DDUser.hasMany(Bump, { + foreignKey: "userId", + as: "Bumps", + }); await sequelize.sync(); sequelizeInstance = sequelize;