diff --git a/.gitignore b/.gitignore index 9b98040..1655f85 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,5 @@ eslint-results.sarif .sentryclirc -.direnv \ No newline at end of file +.direnv +.tldr* \ No newline at end of file diff --git a/DB_CHANGES.md b/DB_CHANGES.md new file mode 100644 index 0000000..ee408bc --- /dev/null +++ b/DB_CHANGES.md @@ -0,0 +1,66 @@ +# Database Changes for Achievement System + +## Summary + +**1 new table** needs to be created. No existing tables require column modifications. + +--- + +## New Table: `DDUserAchievements` + +### Table Configuration + +- **Table Name:** `DDUserAchievements` +- **Paranoid:** Yes (soft deletes with `deletedAt` column) +- **Timestamps:** Auto-managed (`createdAt`, `updatedAt`, `deletedAt`) + +### Columns + +| Column | Type | Constraints | Description | +| --------------- | ------------ | -------------------------------- | ---------------------------------------------------------- | +| `id` | INTEGER | PRIMARY KEY, AUTO INCREMENT | Unique record identifier (auto-generated) | +| `achievementId` | VARCHAR(255) | NOT NULL | Achievement definition ID (e.g., "bump_first", "level_10") | +| `ddUserId` | BIGINT | NOT NULL, FOREIGN KEY → Users.id | Reference to the user who earned the achievement | +| `createdAt` | DATETIME | NOT NULL | When the record was created | +| `updatedAt` | DATETIME | NOT NULL | When the record was last updated | +| `deletedAt` | DATETIME | NULL | Soft delete timestamp (paranoid mode) | + +### Indexes + +| Index Name | Type | Columns | Purpose | +| ----------------------------- | ------ | --------------------------- | ---------------------------------------------- | +| `unique_achievement_ddUserId` | UNIQUE | `achievementId`, `ddUserId` | Prevents duplicate achievement awards per user | + +### Foreign Keys + +| Column | References | On Delete | On Update | +| ---------- | ---------- | ----------------- | ----------------- | +| `ddUserId` | `Users.id` | CASCADE (default) | CASCADE (default) | + +--- + +## Existing Table Changes: `Users` (DDUser) + +**No column changes required.** + +Only a Sequelize relationship decorator was added (`@HasMany`), which creates a TypeScript relationship but does not modify the database schema. + +--- + +## SQL Migration (Reference) + +```sql +-- Create DDUserAchievements table +CREATE TABLE "DDUserAchievements" ( + "id" SERIAL PRIMARY KEY, + "achievementId" VARCHAR(255) NOT NULL, + "ddUserId" BIGINT NOT NULL REFERENCES "Users"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, + "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, + "deletedAt" TIMESTAMP WITH TIME ZONE +); + +-- Create unique composite index +CREATE UNIQUE INDEX "unique_achievement_ddUserId" +ON "DDUserAchievements" ("achievementId", "ddUserId"); +``` diff --git a/src/Config.prod.ts b/src/Config.prod.ts index 9c306f4..1451047 100644 --- a/src/Config.prod.ts +++ b/src/Config.prod.ts @@ -74,7 +74,7 @@ export const config: Config = { }, devbin: { url: "https://devbin.developerden.org", - api_url: "https://devbin-api.developerden.org", + api_url: "https://devbin-api.developerden.org", threshold: 20, }, branding: { @@ -181,4 +181,9 @@ https://discord.gg/devden`), ], ], }, + + achievements: { + notificationMode: "trigger", + fallbackChannel: "821820015917006868", // botCommands + }, }; diff --git a/src/Config.ts b/src/Config.ts index 5dca5de..eec483b 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -85,6 +85,11 @@ const devConfig: Config = { }, informationMessage: prodConfig.informationMessage, + + achievements: { + notificationMode: "trigger", + fallbackChannel: "906954540039938048", // botCommands + }, }; const isProd = process.env.NODE_ENV === "production"; diff --git a/src/config.type.ts b/src/config.type.ts index d672e39..5e459a9 100644 --- a/src/config.type.ts +++ b/src/config.type.ts @@ -2,6 +2,15 @@ import type { Snowflake } from "discord.js"; import type { InformationMessage } from "./modules/information/information.js"; import type { BrandingConfig } from "./util/branding.js"; +export interface AchievementsConfig { + /** Where to send achievement notifications */ + notificationMode: "channel" | "dm" | "trigger"; + /** Dedicated channel for notifications (required if mode is "channel") */ + notificationChannel?: string; + /** Fallback channel if trigger location unavailable (for "trigger" mode) */ + fallbackChannel?: string; +} + export interface ThreatDetectionConfig { enabled: boolean; alertChannel?: Snowflake; @@ -62,11 +71,11 @@ export interface Config { yesEmojiId: string; noEmojiId: string; }; - devbin: { - url: string; - api_url: string; - threshold: number - }; + devbin: { + url: string; + api_url: string; + threshold: number; + }; channels: { welcome: string; botCommands: string; @@ -133,4 +142,5 @@ export interface Config { allowAppeals: boolean; appealCooldown: string; }; + achievements?: AchievementsConfig; } diff --git a/src/index.ts b/src/index.ts index 827b8bc..80eadbd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import * as Sentry from "@sentry/bun"; import * as schedule from "node-schedule"; import { startHealthCheck } from "./healthcheck.js"; import { logger } from "./logging.js"; +import { AchievementsModule } from "./modules/achievements/achievements.module.js"; import AskToAskModule from "./modules/askToAsk.module.js"; import { CoreModule } from "./modules/core/core.module.js"; import FaqModule from "./modules/faq/faq.module.js"; @@ -70,6 +71,7 @@ export const moduleManager = new ModuleManager( ModmailModule, LeaderboardModule, UserModule, + AchievementsModule, ThreatDetectionModule, ], ); diff --git a/src/modules/achievements/achievementDefinitions.ts b/src/modules/achievements/achievementDefinitions.ts new file mode 100644 index 0000000..4fdf6d8 --- /dev/null +++ b/src/modules/achievements/achievementDefinitions.ts @@ -0,0 +1,332 @@ +/** + * Achievement system definitions + * + * Achievements are defined declaratively with conditions that are checked + * when relevant triggers occur (bump, daily, xp events). + */ + +export type AchievementCategory = "bump" | "daily" | "xp" | "special"; + +export type AchievementTrigger = + | { type: "bump"; event: "bump_recorded" } + | { type: "daily"; event: "daily_claimed" } + | { type: "xp"; event: "xp_gained" } + | { type: "manual"; event: "manual_grant" }; + +export interface AchievementContext { + // Bump context + totalBumps?: number; + bumpStreak?: number; + // Daily context + dailyStreak?: number; + // XP context + totalXp?: bigint; + level?: number; +} + +/** Notification mode for achievements */ +export type NotificationMode = "channel" | "dm" | "trigger"; + +export interface AchievementDefinition { + id: string; + name: string; + description: string; + emoji: string; + category: AchievementCategory; + trigger: AchievementTrigger; + checkCondition: (context: AchievementContext) => boolean; + /** Whether the achievement is active. Inactive achievements cannot be awarded and are hidden from display. Defaults to true. */ + active?: boolean; + /** Override the global notification mode for this specific achievement. If not set, uses the global config. */ + notificationMode?: NotificationMode; +} + +// ───────────────────────────────────────────────── +// Bump Achievements +// ───────────────────────────────────────────────── + +const BUMP_ACHIEVEMENTS: AchievementDefinition[] = [ + // Total bumps + { + id: "bump_first", + name: "First Bump", + description: "Bump the server for the first time", + emoji: "🎯", + category: "bump", + trigger: { type: "bump", event: "bump_recorded" }, + checkCondition: (ctx) => (ctx.totalBumps ?? 0) >= 1, + }, + { + id: "bump_10", + name: "Bump Enthusiast", + description: "Bump the server 10 times", + emoji: "🌟", + category: "bump", + trigger: { type: "bump", event: "bump_recorded" }, + checkCondition: (ctx) => (ctx.totalBumps ?? 0) >= 10, + }, + { + id: "bump_50", + name: "Bump Master", + description: "Bump the server 50 times", + emoji: "🏅", + category: "bump", + trigger: { type: "bump", event: "bump_recorded" }, + checkCondition: (ctx) => (ctx.totalBumps ?? 0) >= 50, + }, + { + id: "bump_100", + name: "Bump Legend", + description: "Bump the server 100 times", + emoji: "🏆", + category: "bump", + trigger: { type: "bump", event: "bump_recorded" }, + checkCondition: (ctx) => (ctx.totalBumps ?? 0) >= 100, + }, + // Bump streaks + { + id: "bump_streak_3", + name: "Getting Started", + description: "Achieve a 3-bump streak", + emoji: "🔥", + category: "bump", + trigger: { type: "bump", event: "bump_recorded" }, + checkCondition: (ctx) => (ctx.bumpStreak ?? 0) >= 3, + }, + { + id: "bump_streak_7", + name: "Week Warrior", + description: "Achieve a 7-bump streak", + emoji: "📅", + category: "bump", + trigger: { type: "bump", event: "bump_recorded" }, + checkCondition: (ctx) => (ctx.bumpStreak ?? 0) >= 7, + }, + { + id: "bump_streak_14", + name: "Fortnight Fighter", + description: "Achieve a 14-bump streak", + emoji: "💪", + category: "bump", + trigger: { type: "bump", event: "bump_recorded" }, + checkCondition: (ctx) => (ctx.bumpStreak ?? 0) >= 14, + }, +]; + +// ───────────────────────────────────────────────── +// Daily Achievements +// ───────────────────────────────────────────────── + +const DAILY_ACHIEVEMENTS: AchievementDefinition[] = [ + { + id: "daily_first", + name: "First Daily", + description: "Claim your first daily reward", + emoji: "🌅", + category: "daily", + trigger: { type: "daily", event: "daily_claimed" }, + checkCondition: (ctx) => (ctx.dailyStreak ?? 0) >= 1, + }, + { + id: "daily_streak_7", + name: "Week of Dedication", + description: "Maintain a 7-day daily streak", + emoji: "📆", + category: "daily", + trigger: { type: "daily", event: "daily_claimed" }, + checkCondition: (ctx) => (ctx.dailyStreak ?? 0) >= 7, + }, + { + id: "daily_streak_30", + name: "Month of Mastery", + description: "Maintain a 30-day daily streak", + emoji: "🗓️", + category: "daily", + trigger: { type: "daily", event: "daily_claimed" }, + checkCondition: (ctx) => (ctx.dailyStreak ?? 0) >= 30, + }, + { + id: "daily_streak_100", + name: "Century Club", + description: "Maintain a 100-day daily streak", + emoji: "💯", + category: "daily", + trigger: { type: "daily", event: "daily_claimed" }, + checkCondition: (ctx) => (ctx.dailyStreak ?? 0) >= 100, + }, + { + id: "daily_streak_365", + name: "Year of Commitment", + description: "Maintain a 365-day daily streak", + emoji: "🎉", + category: "daily", + trigger: { type: "daily", event: "daily_claimed" }, + checkCondition: (ctx) => (ctx.dailyStreak ?? 0) >= 365, + }, +]; + +// ───────────────────────────────────────────────── +// XP/Chat Achievements +// ───────────────────────────────────────────────── + +const XP_ACHIEVEMENTS: AchievementDefinition[] = [ + // Level milestones + { + id: "level_1", + name: "First Steps", + description: "Reach level 1", + emoji: "🌱", + category: "xp", + trigger: { type: "xp", event: "xp_gained" }, + checkCondition: (ctx) => (ctx.level ?? 0) >= 1, + }, + { + id: "level_10", + name: "Rising Star", + description: "Reach level 10", + emoji: "⭐", + category: "xp", + trigger: { type: "xp", event: "xp_gained" }, + checkCondition: (ctx) => (ctx.level ?? 0) >= 10, + }, + { + id: "level_25", + name: "Experienced", + description: "Reach level 25", + emoji: "💎", + category: "xp", + trigger: { type: "xp", event: "xp_gained" }, + checkCondition: (ctx) => (ctx.level ?? 0) >= 25, + }, + { + id: "level_50", + name: "Veteran", + description: "Reach level 50", + emoji: "🔷", + category: "xp", + trigger: { type: "xp", event: "xp_gained" }, + checkCondition: (ctx) => (ctx.level ?? 0) >= 50, + }, + // XP milestones + { + id: "xp_1000", + name: "First Thousand", + description: "Earn 1,000 XP", + emoji: "📈", + category: "xp", + trigger: { type: "xp", event: "xp_gained" }, + checkCondition: (ctx) => (ctx.totalXp ?? 0n) >= 1000n, + }, + { + id: "xp_10000", + name: "Ten Thousand Club", + description: "Earn 10,000 XP", + emoji: "🚀", + category: "xp", + trigger: { type: "xp", event: "xp_gained" }, + checkCondition: (ctx) => (ctx.totalXp ?? 0n) >= 10000n, + }, + { + id: "xp_100000", + name: "XP Millionaire", + description: "Earn 100,000 XP", + emoji: "💰", + category: "xp", + trigger: { type: "xp", event: "xp_gained" }, + checkCondition: (ctx) => (ctx.totalXp ?? 0n) >= 100000n, + }, +]; + +// ───────────────────────────────────────────────── +// Special Achievements (Manual Grant Only) +// ───────────────────────────────────────────────── + +const SPECIAL_ACHIEVEMENTS: AchievementDefinition[] = [ + { + id: "project_contributor", + name: "Project Contributor", + description: "Contributed to community projects or the bot itself", + emoji: "🛠️", + category: "special", + trigger: { type: "manual", event: "manual_grant" }, + checkCondition: () => false, // Never auto-awarded + }, +]; + +// ───────────────────────────────────────────────── +// Combined Export +// ───────────────────────────────────────────────── + +export const ACHIEVEMENTS: AchievementDefinition[] = [ + ...BUMP_ACHIEVEMENTS, + ...DAILY_ACHIEVEMENTS, + ...XP_ACHIEVEMENTS, + ...SPECIAL_ACHIEVEMENTS, +]; + +/** + * Get all achievements for a specific category + */ +export function getAchievementsByCategory( + category: AchievementCategory, +): AchievementDefinition[] { + return ACHIEVEMENTS.filter((a) => a.category === category); +} + +/** + * Get an achievement by its ID + */ +export function getAchievementById( + id: string, +): AchievementDefinition | undefined { + return ACHIEVEMENTS.find((a) => a.id === id); +} + +/** + * Get all achievements that match a specific trigger + */ +export function getAchievementsByTrigger( + trigger: AchievementTrigger, +): AchievementDefinition[] { + return ACHIEVEMENTS.filter( + (a) => a.trigger.type === trigger.type && a.trigger.event === trigger.event, + ); +} + +/** + * Get all active achievements (excludes achievements with active: false) + */ +export function getActiveAchievements(): AchievementDefinition[] { + return ACHIEVEMENTS.filter((a) => a.active !== false); +} + +/** + * Get active achievements that match a specific trigger + */ +export function getActiveAchievementsByTrigger( + trigger: AchievementTrigger, +): AchievementDefinition[] { + return getActiveAchievements().filter( + (a) => a.trigger.type === trigger.type && a.trigger.event === trigger.event, + ); +} + +/** + * Get all achievements that can be manually granted (manual trigger type) + */ +export function getManualAchievements(): AchievementDefinition[] { + return getActiveAchievements().filter((a) => a.trigger.type === "manual"); +} + +/** + * Category display information + */ +export const CATEGORY_INFO: Record< + AchievementCategory, + { name: string; emoji: string } +> = { + bump: { name: "Bump Achievements", emoji: "🎯" }, + daily: { name: "Daily Achievements", emoji: "🌅" }, + xp: { name: "XP Achievements", emoji: "📈" }, + special: { name: "Special Achievements", emoji: "🛠️" }, +}; diff --git a/src/modules/achievements/achievementNotifier.ts b/src/modules/achievements/achievementNotifier.ts new file mode 100644 index 0000000..8daec60 --- /dev/null +++ b/src/modules/achievements/achievementNotifier.ts @@ -0,0 +1,305 @@ +/** + * Achievement Notifier + * + * Handles sending notifications when achievements are unlocked. + * Supports multiple notification modes: channel, DM, or trigger location. + */ + +import type { + Client, + GuildMember, + TextBasedChannel, + TextChannel, +} from "discord.js"; +import { config } from "../../Config.js"; +import { logger } from "../../logging.js"; +import { createStandardEmbed } from "../../util/embeds.js"; +import { mentionIfPingable } from "../../util/users.js"; +import type { + AchievementDefinition, + NotificationMode, +} from "./achievementDefinitions.js"; +import { getAchievementProgress } from "./achievementService.js"; + +interface AchievementConfig { + notificationMode: NotificationMode; + notificationChannel?: string; + fallbackChannel?: string; +} + +function getAchievementConfig(): AchievementConfig { + return ( + (config as { achievements?: AchievementConfig }).achievements ?? { + notificationMode: "trigger", + fallbackChannel: config.channels.botCommands, + } + ); +} + +/** + * Send a notification when a user unlocks an achievement. + * + * @param client Discord client + * @param member The guild member who earned the achievement + * @param achievement The achievement definition + * @param triggerChannel Optional channel where the achievement was triggered + */ +export async function notifyAchievementUnlocked( + client: Client, + member: GuildMember, + achievement: AchievementDefinition, + triggerChannel?: TextBasedChannel, +): Promise { + const achievementConfig = getAchievementConfig(); + const progress = await getAchievementProgress(BigInt(member.id)); + + const embed = createStandardEmbed(member) + .setTitle("Achievement Unlocked!") + .setDescription( + `${mentionIfPingable(member)} earned **${achievement.name}**!\n> ${achievement.description}`, + ) + .setThumbnail(member.user.displayAvatarURL({ size: 128 })) + .setFooter({ + text: `${achievement.category.charAt(0).toUpperCase() + achievement.category.slice(1)} Achievements | ${progress.unlocked}/${progress.total} total`, + }); + + // Use per-achievement override if set, otherwise fall back to global config + const mode = + achievement.notificationMode ?? achievementConfig.notificationMode; + + try { + await sendByMode( + client, + member, + embed, + mode, + achievementConfig, + triggerChannel, + ); + } catch (error) { + logger.error( + `Failed to send achievement notification for ${achievement.name} to ${member.id}:`, + error, + ); + } +} + +/** + * Send an embed using the specified notification mode. + * Centralizes the mode-based routing logic. + */ +async function sendByMode( + client: Client, + member: GuildMember, + embed: ReturnType, + mode: NotificationMode, + achievementConfig: AchievementConfig, + triggerChannel?: TextBasedChannel, +): Promise { + switch (mode) { + case "dm": + await sendDM(client, member, embed, achievementConfig); + break; + case "channel": + await sendToChannel(client, embed, achievementConfig.notificationChannel); + break; + case "trigger": + default: + await sendToTriggerLocation( + client, + member, + embed, + triggerChannel, + achievementConfig, + ); + break; + } +} + +/** + * Send notification as DM with fallback to channel. + */ +async function sendDM( + client: Client, + member: GuildMember, + embed: ReturnType, + achievementConfig: AchievementConfig, +): Promise { + try { + await member.send({ embeds: [embed] }); + logger.debug(`Sent achievement DM to ${member.id}`); + } catch { + // DM failed (probably blocked), fall back to channel + logger.debug(`DM failed for ${member.id}, falling back to channel`); + const fallbackChannelId = + achievementConfig.fallbackChannel ?? config.channels.botCommands; + await sendToChannel(client, embed, fallbackChannelId); + } +} + +/** + * Send notification to a specific channel. + */ +async function sendToChannel( + client: Client, + embed: ReturnType, + channelId?: string, +): Promise { + if (!channelId) { + logger.warn("No channel ID provided for achievement notification"); + return; + } + + const channel = await client.channels.fetch(channelId); + if (!channel?.isTextBased()) { + logger.warn(`Channel ${channelId} is not a text channel`); + return; + } + + await (channel as TextChannel).send({ embeds: [embed] }); + logger.debug(`Sent achievement notification to channel ${channelId}`); +} + +/** + * Send notification to the trigger location with fallbacks. + */ +async function sendToTriggerLocation( + client: Client, + member: GuildMember, + embed: ReturnType, + triggerChannel: TextBasedChannel | undefined, + achievementConfig: AchievementConfig, +): Promise { + // Try trigger channel first + if (triggerChannel && "send" in triggerChannel) { + await triggerChannel.send({ embeds: [embed] }); + logger.debug(`Sent achievement notification to trigger channel`); + return; + } + + // Fall back to configured fallback channel + if (achievementConfig.fallbackChannel) { + await sendToChannel(client, embed, achievementConfig.fallbackChannel); + return; + } + + // Final fallback to botCommands + await sendToChannel(client, embed, config.channels.botCommands); +} + +/** + * Notify multiple achievements at once. + * Groups achievements by their notification mode and sends each group appropriately. + */ +export async function notifyMultipleAchievements( + client: Client, + member: GuildMember, + achievements: AchievementDefinition[], + triggerChannel?: TextBasedChannel, +): Promise { + if (achievements.length === 0) return; + + const achievementConfig = getAchievementConfig(); + + // Group achievements by their effective notification mode + const byMode = new Map(); + for (const achievement of achievements) { + const mode = + achievement.notificationMode ?? achievementConfig.notificationMode; + const group = byMode.get(mode) ?? []; + group.push(achievement); + byMode.set(mode, group); + } + + // If all achievements have the same mode, batch them together + if (byMode.size === 1) { + const [mode, groupedAchievements] = [...byMode.entries()][0]; + + // For single achievement, use standard notification + if (groupedAchievements.length === 1) { + await notifyAchievementUnlocked( + client, + member, + groupedAchievements[0], + triggerChannel, + ); + return; + } + + // For multiple achievements with same mode, create combined embed + await sendAchievementGroup( + client, + member, + groupedAchievements, + mode, + achievementConfig, + triggerChannel, + ); + return; + } + + // For achievements with different modes, send each group separately + for (const [mode, groupedAchievements] of byMode) { + if (groupedAchievements.length === 1) { + await notifyAchievementUnlocked( + client, + member, + groupedAchievements[0], + triggerChannel, + ); + } else { + await sendAchievementGroup( + client, + member, + groupedAchievements, + mode, + achievementConfig, + triggerChannel, + ); + } + } +} + +/** + * Send a group of achievements with a specific notification mode. + */ +async function sendAchievementGroup( + client: Client, + member: GuildMember, + achievements: AchievementDefinition[], + mode: NotificationMode, + achievementConfig: AchievementConfig, + triggerChannel?: TextBasedChannel, +): Promise { + const progress = await getAchievementProgress(BigInt(member.id)); + + const achievementList = achievements + .map((a) => `${a.emoji} **${a.name}** - ${a.description}`) + .join("\n"); + + const embed = createStandardEmbed(member) + .setTitle(`${achievements.length} Achievements Unlocked!`) + .setDescription( + `${mentionIfPingable(member)} earned:\n\n${achievementList}`, + ) + .setThumbnail(member.user.displayAvatarURL({ size: 128 })) + .setFooter({ + text: `${progress.unlocked}/${progress.total} achievements unlocked`, + }); + + try { + await sendByMode( + client, + member, + embed, + mode, + achievementConfig, + triggerChannel, + ); + } catch (error) { + logger.error( + `Failed to send achievement group notifications to ${member.id}:`, + error, + ); + } +} diff --git a/src/modules/achievements/achievementService.ts b/src/modules/achievements/achievementService.ts new file mode 100644 index 0000000..8c0b41e --- /dev/null +++ b/src/modules/achievements/achievementService.ts @@ -0,0 +1,328 @@ +/** + * Achievement Service + * + * Core logic for checking and awarding achievements to users. + */ + +import * as Sentry from "@sentry/node"; +import { UniqueConstraintError } from "@sequelize/core"; +import { logger } from "../../logging.js"; +import type { DDUser } from "../../store/models/DDUser.js"; +import { DDUserAchievements } from "../../store/models/DDUserAchievements.js"; +import { + type AchievementCategory, + type AchievementContext, + type AchievementDefinition, + type AchievementTrigger, + getAchievementById, + getActiveAchievements, + getActiveAchievementsByTrigger, +} from "./achievementDefinitions.js"; + +export interface AwardedAchievement { + definition: AchievementDefinition; + awardedAt: Date; +} + +export interface UserAchievementRecord { + achievementId: string; + awardedAt: Date; +} + +/** + * Check and award achievements for a user based on a trigger event. + * + * @param user The DDUser to check achievements for + * @param trigger The trigger that caused this check (bump, daily, xp) + * @param context Additional context needed to evaluate achievement conditions + * @returns Array of newly awarded achievements (empty if none were awarded) + */ +export async function checkAndAwardAchievements( + user: DDUser, + trigger: AchievementTrigger, + context: AchievementContext, +): Promise { + return await Sentry.startSpan( + { + name: "checkAndAwardAchievements", + attributes: { + userId: user.id.toString(), + triggerType: trigger.type, + triggerEvent: trigger.event, + }, + }, + async () => { + const relevantAchievements = getActiveAchievementsByTrigger(trigger); + const newlyAwarded: AwardedAchievement[] = []; + + // Get user's existing achievements + const existingAchievements = await getUserAchievementIds(user.id); + + for (const achievement of relevantAchievements) { + // Skip if already has this achievement + if (existingAchievements.has(achievement.id)) { + continue; + } + + // Check if user meets the condition + if (achievement.checkCondition(context)) { + const awarded = await awardAchievement(user.id, achievement.id); + if (awarded) { + newlyAwarded.push({ + definition: achievement, + awardedAt: new Date(), + }); + logger.info( + `Awarded achievement "${achievement.name}" to user ${user.id}`, + ); + } + } + } + + return newlyAwarded; + }, + ); +} + +/** + * Award a specific achievement to a user. + * + * @returns true if the achievement was newly awarded, false if already had it + */ +async function awardAchievement( + userId: bigint, + achievementId: string, +): Promise { + try { + await DDUserAchievements.create({ + achievementId, + ddUserId: userId, + }); + return true; + } catch (error) { + // Unique constraint violation means they already have it + if (error instanceof UniqueConstraintError) { + return false; + } + throw error; + } +} + +/** + * Get all achievement IDs that a user has earned. + */ +async function getUserAchievementIds(userId: bigint): Promise> { + const records = await DDUserAchievements.findAll({ + where: { ddUserId: userId }, + attributes: ["achievementId"], + }); + return new Set(records.map((r) => r.achievementId)); +} + +/** + * Get all achievements for a user with their award dates. + */ +export async function getUserAchievements( + userId: bigint, +): Promise { + const records = await DDUserAchievements.findAll({ + where: { ddUserId: userId }, + order: [["createdAt", "ASC"]], + }); + + return records.map((r) => ({ + achievementId: r.achievementId, + awardedAt: r.createdAt as Date, + })); +} + +/** + * Check if a user has a specific achievement. + */ +export async function hasAchievement( + userId: bigint, + achievementId: string, +): Promise { + const record = await DDUserAchievements.findOne({ + where: { + ddUserId: userId, + achievementId, + }, + }); + return record !== null; +} + +export interface GrantResult { + success: boolean; + alreadyHad: boolean; + error?: string; +} + +export interface RevokeResult { + success: boolean; + didNotHave: boolean; + error?: string; +} + +/** + * Manually grant an achievement to a user. + * Used by moderation commands to award achievements that can't be earned automatically. + * + * @param userId The user ID to grant the achievement to + * @param achievementId The achievement ID to grant + * @returns Result indicating success, already had, or error + */ +export async function grantAchievement( + userId: bigint, + achievementId: string, +): Promise { + const achievement = getAchievementById(achievementId); + if (!achievement) { + return { + success: false, + alreadyHad: false, + error: "Achievement not found", + }; + } + + if (achievement.active === false) { + return { + success: false, + alreadyHad: false, + error: "Achievement is inactive", + }; + } + + const awarded = await awardAchievement(userId, achievementId); + if (!awarded) { + return { success: false, alreadyHad: true }; + } + + logger.info( + `Manually granted achievement "${achievement.name}" to user ${userId}`, + ); + return { success: true, alreadyHad: false }; +} + +/** + * Revoke an achievement from a user. + * Uses soft delete (paranoid mode) to maintain audit trail. + * + * @param userId The user ID to revoke the achievement from + * @param achievementId The achievement ID to revoke + * @returns Result indicating success, didn't have, or error + */ +export async function revokeAchievement( + userId: bigint, + achievementId: string, +): Promise { + const achievement = getAchievementById(achievementId); + if (!achievement) { + return { + success: false, + didNotHave: false, + error: "Achievement not found", + }; + } + + const record = await DDUserAchievements.findOne({ + where: { + ddUserId: userId, + achievementId, + }, + }); + + if (!record) { + return { success: false, didNotHave: true }; + } + + await record.destroy(); // Soft delete (sets deletedAt) + logger.info(`Revoked achievement "${achievement.name}" from user ${userId}`); + return { success: true, didNotHave: false }; +} + +/** + * Get achievement progress stats for a user. + */ +export async function getAchievementProgress(userId: bigint): Promise<{ + total: number; + unlocked: number; + byCategory: Record; +}> { + const userAchievements = await getUserAchievementIds(userId); + + const byCategory: Record< + AchievementCategory, + { total: number; unlocked: number } + > = { + bump: { total: 0, unlocked: 0 }, + daily: { total: 0, unlocked: 0 }, + xp: { total: 0, unlocked: 0 }, + special: { total: 0, unlocked: 0 }, + }; + + const activeAchievements = getActiveAchievements(); + for (const achievement of activeAchievements) { + byCategory[achievement.category].total++; + if (userAchievements.has(achievement.id)) { + byCategory[achievement.category].unlocked++; + } + } + + return { + total: activeAchievements.length, + unlocked: userAchievements.size, + byCategory, + }; +} + +/** + * Get detailed achievement info for display, including unlock status. + */ +export async function getAchievementsWithStatus(userId: bigint): Promise< + Array<{ + definition: AchievementDefinition; + unlocked: boolean; + unlockedAt?: Date; + }> +> { + const records = await getUserAchievements(userId); + const recordMap = new Map(records.map((r) => [r.achievementId, r.awardedAt])); + + return getActiveAchievements().map((definition) => { + const unlockedAt = recordMap.get(definition.id); + return { + definition, + unlocked: unlockedAt !== undefined, + unlockedAt, + }; + }); +} + +/** + * Get achievement definition with user's unlock status. + */ +export async function getAchievementWithStatus( + userId: bigint, + achievementId: string, +): Promise<{ + definition: AchievementDefinition; + unlocked: boolean; + unlockedAt?: Date; +} | null> { + const definition = getAchievementById(achievementId); + if (!definition) return null; + + const record = await DDUserAchievements.findOne({ + where: { + ddUserId: userId, + achievementId, + }, + }); + + return { + definition, + unlocked: record !== null, + unlockedAt: record?.createdAt as Date | undefined, + }; +} diff --git a/src/modules/achievements/achievements.command.ts b/src/modules/achievements/achievements.command.ts new file mode 100644 index 0000000..ce722e8 --- /dev/null +++ b/src/modules/achievements/achievements.command.ts @@ -0,0 +1,134 @@ +/** + * /achievements command + * + * Displays a user's achievements grouped by category. + */ + +import { + ApplicationCommandOptionType, + ApplicationCommandType, + type GuildMember, +} from "discord.js"; +import type { Command } from "djs-slash-helper"; +import { wrapInTransaction } from "../../sentry.js"; +import { createStandardEmbed } from "../../util/embeds.js"; +import { getResolvedMember } from "../../util/interactions.js"; +import { fakeMention } from "../../util/users.js"; +import { + type AchievementCategory, + CATEGORY_INFO, +} from "./achievementDefinitions.js"; +import { + getAchievementProgress, + getAchievementsWithStatus, +} from "./achievementService.js"; + +const CATEGORY_ORDER: AchievementCategory[] = [ + "bump", + "daily", + "xp", + "special", +]; + +export const AchievementsCommand: Command = { + name: "achievements", + type: ApplicationCommandType.ChatInput, + description: "View achievements for yourself or another member", + options: [ + { + type: ApplicationCommandOptionType.User, + name: "member", + description: "The member to view achievements for", + required: false, + }, + { + type: ApplicationCommandOptionType.String, + name: "category", + description: "Filter by category", + required: false, + choices: [ + { name: "Bump Achievements", value: "bump" }, + { name: "Daily Achievements", value: "daily" }, + { name: "XP Achievements", value: "xp" }, + { name: "Special Achievements", value: "special" }, + ], + }, + ], + + handle: wrapInTransaction("achievements", async (_, interaction) => { + await interaction.deferReply(); + + const targetUser = + interaction.options.get("member")?.user ?? interaction.user; + const member = + getResolvedMember(interaction.options.get("member")?.member) ?? + (await interaction.guild?.members.fetch(targetUser.id)); + + if (!member) { + await interaction.followUp("Member not found"); + return; + } + + const categoryFilter = interaction.options.get("category")?.value as + | AchievementCategory + | undefined; + + const userId = BigInt(targetUser.id); + const achievementsWithStatus = await getAchievementsWithStatus(userId); + const progress = await getAchievementProgress(userId); + + // Build the embed + const embed = createStandardEmbed(member as GuildMember) + .setTitle(`Achievements for ${fakeMention(targetUser)}`) + .setThumbnail(targetUser.displayAvatarURL({ size: 128 })); + + // Filter categories if specified + const categoriesToShow = categoryFilter ? [categoryFilter] : CATEGORY_ORDER; + + for (const category of categoriesToShow) { + const categoryInfo = CATEGORY_INFO[category]; + const categoryAchievements = achievementsWithStatus.filter( + (a) => a.definition.category === category, + ); + + const categoryProgress = progress.byCategory[category]; + + // Build achievement lines + const lines = categoryAchievements.map((achievement) => { + const { definition, unlocked, unlockedAt } = achievement; + if (unlocked && unlockedAt) { + const dateStr = formatDate(unlockedAt); + return `✅ ${definition.emoji} ${definition.name} - *${dateStr}*`; + } + return `⬛ ${definition.emoji} ${definition.name}`; + }); + + const fieldName = `${categoryInfo.emoji} ${categoryInfo.name} (${categoryProgress.unlocked}/${categoryProgress.total})`; + const fieldValue = lines.join("\n") || "No achievements in this category"; + + embed.addFields({ + name: fieldName, + value: fieldValue, + inline: false, + }); + } + + // Add progress footer + embed.setFooter({ + text: `Progress: ${progress.unlocked}/${progress.total} achievements unlocked`, + }); + + await interaction.followUp({ embeds: [embed] }); + }), +}; + +/** + * Format a date for display. + */ +function formatDate(date: Date): string { + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} diff --git a/src/modules/achievements/achievements.module.ts b/src/modules/achievements/achievements.module.ts new file mode 100644 index 0000000..439703e --- /dev/null +++ b/src/modules/achievements/achievements.module.ts @@ -0,0 +1,20 @@ +/** + * Achievements Module + * + * Provides achievement tracking and display for users. + */ + +import type Module from "../module.js"; +import { AchievementsCommand } from "./achievements.command.js"; +import { GrantAchievementCommand } from "./grantAchievement.command.js"; +import { RevokeAchievementCommand } from "./revokeAchievement.command.js"; + +export const AchievementsModule: Module = { + name: "achievements", + commands: [ + AchievementsCommand, + GrantAchievementCommand, + RevokeAchievementCommand, + ], + listeners: [], +}; diff --git a/src/modules/achievements/grantAchievement.command.ts b/src/modules/achievements/grantAchievement.command.ts new file mode 100644 index 0000000..ba1cc77 --- /dev/null +++ b/src/modules/achievements/grantAchievement.command.ts @@ -0,0 +1,123 @@ +/** + * /grant-achievement command + * + * Moderation command to manually grant achievements to users. + * Only staff members can use this command. + */ + +import { + ApplicationCommandOptionType, + ApplicationCommandType, +} from "discord.js"; +import type { Command } from "djs-slash-helper"; +import { wrapInTransaction } from "../../sentry.js"; +import { fakeMention } from "../../util/users.js"; +import { + getAchievementById, + getManualAchievements, +} from "./achievementDefinitions.js"; +import { notifyAchievementUnlocked } from "./achievementNotifier.js"; +import { grantAchievement } from "./achievementService.js"; + +// Build choices from manual achievements at startup +const achievementChoices = getManualAchievements().map((a) => ({ + name: `${a.emoji} ${a.name}`, + value: a.id, +})); + +export const GrantAchievementCommand: Command = + { + name: "grant-achievement", + type: ApplicationCommandType.ChatInput, + description: "Grant an achievement to a user (staff only)", + default_permission: false, + options: [ + { + type: ApplicationCommandOptionType.User, + name: "user", + description: "The user to grant the achievement to", + required: true, + }, + { + type: ApplicationCommandOptionType.String, + name: "achievement", + description: "The achievement to grant", + required: true, + choices: achievementChoices, + }, + { + type: ApplicationCommandOptionType.Boolean, + name: "silent", + description: "Don't send a notification to the user (default: false)", + required: false, + }, + ], + + handle: wrapInTransaction("grant-achievement", async (_, interaction) => { + if ( + !interaction.isChatInputCommand() || + !interaction.inGuild() || + interaction.guild === null + ) + return; + + await interaction.deferReply({ ephemeral: true }); + + const user = interaction.options.getUser("user", true); + const achievementId = interaction.options.getString("achievement", true); + const silent = interaction.options.getBoolean("silent") ?? false; + + // Validate achievement exists and is manual + const achievement = getAchievementById(achievementId); + if (!achievement) { + await interaction.followUp({ + content: `Achievement \`${achievementId}\` not found.`, + ephemeral: true, + }); + return; + } + + if (achievement.trigger.type !== "manual") { + await interaction.followUp({ + content: `Achievement **${achievement.name}** cannot be manually granted. Only special achievements can be granted manually.`, + ephemeral: true, + }); + return; + } + + // Grant the achievement + const result = await grantAchievement(BigInt(user.id), achievementId); + + if (result.error) { + await interaction.followUp({ + content: `Failed to grant achievement: ${result.error}`, + ephemeral: true, + }); + return; + } + + if (result.alreadyHad) { + await interaction.followUp({ + content: `${fakeMention(user)} already has the **${achievement.name}** achievement.`, + ephemeral: true, + }); + return; + } + + // Send notification unless silent + if (!silent) { + const member = await interaction.guild.members.fetch(user.id); + await notifyAchievementUnlocked( + interaction.client, + member, + achievement, + interaction.channel ?? undefined, + ); + } + + await interaction.followUp({ + content: `Successfully granted **${achievement.emoji} ${achievement.name}** to ${fakeMention(user)}${silent ? " (silently)" : ""}.`, + ephemeral: true, + }); + }), + }; diff --git a/src/modules/achievements/revokeAchievement.command.ts b/src/modules/achievements/revokeAchievement.command.ts new file mode 100644 index 0000000..0e966f7 --- /dev/null +++ b/src/modules/achievements/revokeAchievement.command.ts @@ -0,0 +1,96 @@ +/** + * /revoke-achievement command + * + * Moderation command to revoke achievements from users. + * Only staff members can use this command. + */ + +import { + ApplicationCommandOptionType, + ApplicationCommandType, +} from "discord.js"; +import type { Command } from "djs-slash-helper"; +import { wrapInTransaction } from "../../sentry.js"; +import { fakeMention } from "../../util/users.js"; +import { + getAchievementById, + getManualAchievements, +} from "./achievementDefinitions.js"; +import { revokeAchievement } from "./achievementService.js"; + +// Build choices from manual achievements at startup +const achievementChoices = getManualAchievements().map((a) => ({ + name: `${a.emoji} ${a.name}`, + value: a.id, +})); + +export const RevokeAchievementCommand: Command = + { + name: "revoke-achievement", + type: ApplicationCommandType.ChatInput, + description: "Revoke an achievement from a user (staff only)", + default_permission: false, + options: [ + { + type: ApplicationCommandOptionType.User, + name: "user", + description: "The user to revoke the achievement from", + required: true, + }, + { + type: ApplicationCommandOptionType.String, + name: "achievement", + description: "The achievement to revoke", + required: true, + choices: achievementChoices, + }, + ], + + handle: wrapInTransaction("revoke-achievement", async (_, interaction) => { + if ( + !interaction.isChatInputCommand() || + !interaction.inGuild() || + interaction.guild === null + ) + return; + + await interaction.deferReply({ ephemeral: true }); + + const user = interaction.options.getUser("user", true); + const achievementId = interaction.options.getString("achievement", true); + + // Validate achievement exists + const achievement = getAchievementById(achievementId); + if (!achievement) { + await interaction.followUp({ + content: `Achievement \`${achievementId}\` not found.`, + ephemeral: true, + }); + return; + } + + // Revoke the achievement + const result = await revokeAchievement(BigInt(user.id), achievementId); + + if (result.error) { + await interaction.followUp({ + content: `Failed to revoke achievement: ${result.error}`, + ephemeral: true, + }); + return; + } + + if (result.didNotHave) { + await interaction.followUp({ + content: `${fakeMention(user)} doesn't have the **${achievement.name}** achievement.`, + ephemeral: true, + }); + return; + } + + await interaction.followUp({ + content: `Successfully revoked **${achievement.emoji} ${achievement.name}** from ${fakeMention(user)}.`, + ephemeral: true, + }); + }), + }; diff --git a/src/modules/core/bump.listener.test.ts b/src/modules/core/bump.listener.test.ts index 510c090..da523c1 100644 --- a/src/modules/core/bump.listener.test.ts +++ b/src/modules/core/bump.listener.test.ts @@ -1,11 +1,35 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; -import type { Client, Message, MessageInteractionMetadata, PartialTextBasedChannelFields } from "discord.js"; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import type { + Client, + Message, + MessageInteractionMetadata, + PartialTextBasedChannelFields, +} from "discord.js"; import { Bump } from "../../store/models/Bump.js"; import { clearBumpsCache } from "../../store/models/bumps.js"; -import { clearUserCache, getOrCreateUserById } from "../../store/models/DDUser.js"; +import { + clearUserCache, + getOrCreateUserById, +} from "../../store/models/DDUser.js"; import { getSequelizeInstance, initStorage } from "../../store/storage.js"; -import { createMockClient, createMockTextChannel, createMockUser } from "../../tests/mocks/discord.js"; -import { handleBumpStreak, sendBumpNotification, setLastBumpNotificationTime } from "./bump.listener.js"; +import { + createMockClient, + createMockTextChannel, + createMockUser, +} from "../../tests/mocks/discord.js"; +import { + handleBumpStreak, + sendBumpNotification, + setLastBumpNotificationTime, +} from "./bump.listener.js"; beforeAll(async () => { await initStorage(); @@ -18,335 +42,340 @@ afterEach(async () => { }); describe("handleBumpStreak", () => { - const createTestContext = async () => { - const fakeUserId = 1n; - const ddUser = await getOrCreateUserById(fakeUserId); - - const fakeUser = createMockUser({ id: fakeUserId.toString() }); - const mockReact = mock(async () => Promise.resolve()); - const mockChannelSend = mock(async (_content: unknown) => Promise.resolve({} as Message)); - const mockChannel = createMockTextChannel({ send: mockChannelSend }); - const mockClient = createMockClient(); - - const message = { - react: mockReact, - channel: mockChannel - } as unknown as Message & { channel: PartialTextBasedChannelFields }; - - const interaction = { - user: fakeUser - } as unknown as MessageInteractionMetadata; - - return { - ddUser, - fakeUser, - mockReact, - mockChannelSend, - mockChannel, - mockClient, - message, - interaction - }; - }; - - test("adds single heart reaction for first bump", async () => { - const { ddUser, interaction, message, mockReact, mockClient } = - await createTestContext(); - - // Create the user's first bump - await Bump.create({ - messageId: 100n, - userId: ddUser.id, - timestamp: new Date() - }); - clearBumpsCache(); - - await handleBumpStreak( - ddUser, - interaction, - message, - mockClient as unknown as Client - ); - - expect(mockReact).toHaveBeenCalledTimes(1); - expect(mockReact).toHaveBeenCalledWith("❤️"); - }); - - test("adds multiple reactions for streak of 3", async () => { - const { ddUser, interaction, message, mockReact, mockClient } = - await createTestContext(); - - // Create 3 consecutive bumps for the user - await Bump.create({ - messageId: 100n, - userId: ddUser.id, - timestamp: new Date(Date.now() - 3000) - }); - await Bump.create({ - messageId: 101n, - userId: ddUser.id, - timestamp: new Date(Date.now() - 2000) - }); - await Bump.create({ - messageId: 102n, - userId: ddUser.id, - timestamp: new Date(Date.now() - 1000) - }); - clearBumpsCache(); - - await handleBumpStreak( - ddUser, - interaction, - message, - mockClient as unknown as Client - ); - - expect(mockReact).toHaveBeenCalledTimes(3); - expect(mockReact).toHaveBeenNthCalledWith(1, "❤️"); - expect(mockReact).toHaveBeenNthCalledWith(2, "🩷"); - expect(mockReact).toHaveBeenNthCalledWith(3, "🧡"); - }); - - test("announces personal record when streak >= 3 and matches highest", async () => { - const { - ddUser, - interaction, - message, - mockChannelSend, - mockClient - } = await createTestContext(); - - // Create 3 consecutive bumps (first time reaching streak of 3) - await Bump.create({ - messageId: 100n, - userId: ddUser.id, - timestamp: new Date(Date.now() - 3000) - }); - await Bump.create({ - messageId: 101n, - userId: ddUser.id, - timestamp: new Date(Date.now() - 2000) - }); + const createTestContext = async () => { + const fakeUserId = 1n; + const ddUser = await getOrCreateUserById(fakeUserId); + + const fakeUser = createMockUser({ id: fakeUserId.toString() }); + const mockReact = mock(async () => Promise.resolve()); + const mockChannelSend = mock(async (_content: unknown) => + Promise.resolve({} as Message), + ); + const mockChannel = createMockTextChannel({ send: mockChannelSend }); + const mockClient = createMockClient(); + + const message = { + react: mockReact, + channel: mockChannel, + } as unknown as Message & { channel: PartialTextBasedChannelFields }; + + const interaction = { + user: fakeUser, + } as unknown as MessageInteractionMetadata; + + return { + ddUser, + fakeUser, + mockReact, + mockChannelSend, + mockChannel, + mockClient, + message, + interaction, + }; + }; + + test("adds single heart reaction for first bump", async () => { + const { ddUser, interaction, message, mockReact, mockClient } = + await createTestContext(); + + // Create the user's first bump await Bump.create({ - messageId: 102n, - userId: ddUser.id, - timestamp: new Date(Date.now() - 1000) + messageId: 100n, + userId: ddUser.id, + timestamp: new Date(), }); - clearBumpsCache(); - - await handleBumpStreak( - ddUser, - interaction, - message, - mockClient as unknown as Client - ); - - // Should announce the personal record - const calls = mockChannelSend.mock.calls; - const hasPersonalRecordMessage = calls.some( - (call) => - typeof call[0] === "string" && - call[0].includes("beat your max bump streak") - ); - expect(hasPersonalRecordMessage).toBe(true); - }); - - test("detects dethrone when breaking streak > 2", async () => { - const { ddUser, interaction, message, mockChannelSend, mockClient } = - await createTestContext(); - - // Create another user with a streak of 3 - const otherUserId = 2n; - const otherUser = await getOrCreateUserById(otherUserId); - - await Bump.create({ - messageId: 100n, - userId: otherUser.id, - timestamp: new Date(Date.now() - 4000) - }); - await Bump.create({ - messageId: 101n, - userId: otherUser.id, - timestamp: new Date(Date.now() - 3000) - }); - await Bump.create({ - messageId: 102n, - userId: otherUser.id, - timestamp: new Date(Date.now() - 2000) - }); - - // Now our test user bumps, breaking the streak - await Bump.create({ - messageId: 103n, - userId: ddUser.id, - timestamp: new Date(Date.now() - 1000) - }); - clearBumpsCache(); - - await handleBumpStreak( - ddUser, - interaction, - message, - mockClient as unknown as Client - ); - - // Should announce the dethrone - const calls = mockChannelSend.mock.calls; - const hasDethroneMessage = calls.some( - (call) => - typeof call[0] === "string" && call[0].includes("ended") && call[0].includes("streak") - ); - expect(hasDethroneMessage).toBe(true); - }); - - test("does not announce dethrone for streak of 2 or less", async () => { - const { ddUser, interaction, message, mockChannelSend, mockClient } = - await createTestContext(); - - // Create another user with a streak of only 2 - const otherUserId = 2n; - const otherUser = await getOrCreateUserById(otherUserId); - - await Bump.create({ - messageId: 100n, - userId: otherUser.id, - timestamp: new Date(Date.now() - 3000) - }); - await Bump.create({ - messageId: 101n, - userId: otherUser.id, - timestamp: new Date(Date.now() - 2000) - }); - - // Now our test user bumps - await Bump.create({ - messageId: 102n, - userId: ddUser.id, - timestamp: new Date(Date.now() - 1000) - }); - clearBumpsCache(); - - await handleBumpStreak( - ddUser, - interaction, - message, - mockClient as unknown as Client - ); - - // Should NOT announce the dethrone - const calls = mockChannelSend.mock.calls; - const hasDethroneMessage = calls.some( - (call) => - typeof call[0] === "string" && call[0].includes("ended") && call[0].includes("streak") - ); - expect(hasDethroneMessage).toBe(false); - }); + clearBumpsCache(); + + await handleBumpStreak( + ddUser, + interaction, + message, + mockClient as unknown as Client, + ); + + expect(mockReact).toHaveBeenCalledTimes(1); + expect(mockReact).toHaveBeenCalledWith("❤️"); + }); + + test("adds multiple reactions for streak of 3", async () => { + const { ddUser, interaction, message, mockReact, mockClient } = + await createTestContext(); + + // Create 3 consecutive bumps for the user + await Bump.create({ + messageId: 100n, + userId: ddUser.id, + timestamp: new Date(Date.now() - 3000), + }); + await Bump.create({ + messageId: 101n, + userId: ddUser.id, + timestamp: new Date(Date.now() - 2000), + }); + await Bump.create({ + messageId: 102n, + userId: ddUser.id, + timestamp: new Date(Date.now() - 1000), + }); + clearBumpsCache(); + + await handleBumpStreak( + ddUser, + interaction, + message, + mockClient as unknown as Client, + ); + + expect(mockReact).toHaveBeenCalledTimes(3); + expect(mockReact).toHaveBeenNthCalledWith(1, "❤️"); + expect(mockReact).toHaveBeenNthCalledWith(2, "🩷"); + expect(mockReact).toHaveBeenNthCalledWith(3, "🧡"); + }); + + test("announces personal record when streak >= 3 and matches highest", async () => { + const { ddUser, interaction, message, mockChannelSend, mockClient } = + await createTestContext(); + + // Create 3 consecutive bumps (first time reaching streak of 3) + await Bump.create({ + messageId: 100n, + userId: ddUser.id, + timestamp: new Date(Date.now() - 3000), + }); + await Bump.create({ + messageId: 101n, + userId: ddUser.id, + timestamp: new Date(Date.now() - 2000), + }); + await Bump.create({ + messageId: 102n, + userId: ddUser.id, + timestamp: new Date(Date.now() - 1000), + }); + clearBumpsCache(); + + await handleBumpStreak( + ddUser, + interaction, + message, + mockClient as unknown as Client, + ); + + // Should announce the personal record + const calls = mockChannelSend.mock.calls; + const hasPersonalRecordMessage = calls.some( + (call) => + typeof call[0] === "string" && + call[0].includes("beat your max bump streak"), + ); + expect(hasPersonalRecordMessage).toBe(true); + }); + + test("detects dethrone when breaking streak > 2", async () => { + const { ddUser, interaction, message, mockChannelSend, mockClient } = + await createTestContext(); + + // Create another user with a streak of 3 + const otherUserId = 2n; + const otherUser = await getOrCreateUserById(otherUserId); + + await Bump.create({ + messageId: 100n, + userId: otherUser.id, + timestamp: new Date(Date.now() - 4000), + }); + await Bump.create({ + messageId: 101n, + userId: otherUser.id, + timestamp: new Date(Date.now() - 3000), + }); + await Bump.create({ + messageId: 102n, + userId: otherUser.id, + timestamp: new Date(Date.now() - 2000), + }); + + // Now our test user bumps, breaking the streak + await Bump.create({ + messageId: 103n, + userId: ddUser.id, + timestamp: new Date(Date.now() - 1000), + }); + clearBumpsCache(); + + await handleBumpStreak( + ddUser, + interaction, + message, + mockClient as unknown as Client, + ); + + // Should announce the dethrone + const calls = mockChannelSend.mock.calls; + const hasDethroneMessage = calls.some( + (call) => + typeof call[0] === "string" && + call[0].includes("ended") && + call[0].includes("streak"), + ); + expect(hasDethroneMessage).toBe(true); + }); + + test("does not announce dethrone for streak of 2 or less", async () => { + const { ddUser, interaction, message, mockChannelSend, mockClient } = + await createTestContext(); + + // Create another user with a streak of only 2 + const otherUserId = 2n; + const otherUser = await getOrCreateUserById(otherUserId); + + await Bump.create({ + messageId: 100n, + userId: otherUser.id, + timestamp: new Date(Date.now() - 3000), + }); + await Bump.create({ + messageId: 101n, + userId: otherUser.id, + timestamp: new Date(Date.now() - 2000), + }); + + // Now our test user bumps + await Bump.create({ + messageId: 102n, + userId: ddUser.id, + timestamp: new Date(Date.now() - 1000), + }); + clearBumpsCache(); + + await handleBumpStreak( + ddUser, + interaction, + message, + mockClient as unknown as Client, + ); + + // Should NOT announce the dethrone + const calls = mockChannelSend.mock.calls; + const hasDethroneMessage = calls.some( + (call) => + typeof call[0] === "string" && + call[0].includes("ended") && + call[0].includes("streak"), + ); + expect(hasDethroneMessage).toBe(false); + }); }); describe("handleBumpStreak - lightning speed", () => { - test("announces lightning bump when < 30 seconds after notification", async () => { - const fakeUserId = 1n; - const ddUser = await getOrCreateUserById(fakeUserId); - const fakeUser = createMockUser({ id: fakeUserId.toString() }); - const mockReact = mock(async () => Promise.resolve()); - const mockChannelSend = mock(async (_content: unknown) => Promise.resolve({} as Message)); - const mockChannel = createMockTextChannel({ send: mockChannelSend }); - const mockClient = createMockClient(); - - const message = { - react: mockReact, - channel: mockChannel - } as unknown as Message & { channel: PartialTextBasedChannelFields }; - - const interaction = { - user: fakeUser - } as unknown as MessageInteractionMetadata; - - // Set last notification time to 10 seconds ago - setLastBumpNotificationTime(new Date(Date.now() - 10000)); - - await Bump.create({ - messageId: 100n, - userId: ddUser.id, - timestamp: new Date() + test("announces lightning bump when < 30 seconds after notification", async () => { + const fakeUserId = 1n; + const ddUser = await getOrCreateUserById(fakeUserId); + const fakeUser = createMockUser({ id: fakeUserId.toString() }); + const mockReact = mock(async () => Promise.resolve()); + const mockChannelSend = mock(async (_content: unknown) => + Promise.resolve({} as Message), + ); + const mockChannel = createMockTextChannel({ send: mockChannelSend }); + const mockClient = createMockClient(); + + const message = { + react: mockReact, + channel: mockChannel, + } as unknown as Message & { channel: PartialTextBasedChannelFields }; + + const interaction = { + user: fakeUser, + } as unknown as MessageInteractionMetadata; + + // Set last notification time to 10 seconds ago + setLastBumpNotificationTime(new Date(Date.now() - 10000)); + + await Bump.create({ + messageId: 100n, + userId: ddUser.id, + timestamp: new Date(), + }); + clearBumpsCache(); + + await handleBumpStreak( + ddUser, + interaction, + message, + mockClient as unknown as Client, + ); + + const calls = mockChannelSend.mock.calls; + const hasLightningMessage = calls.some( + (call) => typeof call[0] === "string" && call[0].includes("⚡"), + ); + expect(hasLightningMessage).toBe(true); + }); + + test("does not announce lightning bump when >= 30 seconds", async () => { + const fakeUserId = 3n; + const ddUser = await getOrCreateUserById(fakeUserId); + const fakeUser = createMockUser({ id: fakeUserId.toString() }); + const mockReact = mock(async () => Promise.resolve()); + const mockChannelSend = mock(async (_content: unknown) => + Promise.resolve({} as Message), + ); + const mockChannel = createMockTextChannel({ send: mockChannelSend }); + const mockClient = createMockClient(); + + const message = { + react: mockReact, + channel: mockChannel, + } as unknown as Message & { channel: PartialTextBasedChannelFields }; + + const interaction = { + user: fakeUser, + } as unknown as MessageInteractionMetadata; + + // Set last notification time to 60 seconds ago + setLastBumpNotificationTime(new Date(Date.now() - 60000)); + + await Bump.create({ + messageId: 200n, + userId: ddUser.id, + timestamp: new Date(), }); - clearBumpsCache(); - - await handleBumpStreak( - ddUser, - interaction, - message, - mockClient as unknown as Client - ); - - const calls = mockChannelSend.mock.calls; - const hasLightningMessage = calls.some( - (call) => typeof call[0] === "string" && call[0].includes("⚡") - ); - expect(hasLightningMessage).toBe(true); - }); - - test("does not announce lightning bump when >= 30 seconds", async () => { - const fakeUserId = 3n; - const ddUser = await getOrCreateUserById(fakeUserId); - const fakeUser = createMockUser({ id: fakeUserId.toString() }); - const mockReact = mock(async () => Promise.resolve()); - const mockChannelSend = mock(async (_content: unknown) => Promise.resolve({} as Message)); - const mockChannel = createMockTextChannel({ send: mockChannelSend }); - const mockClient = createMockClient(); - - const message = { - react: mockReact, - channel: mockChannel - } as unknown as Message & { channel: PartialTextBasedChannelFields }; - - const interaction = { - user: fakeUser - } as unknown as MessageInteractionMetadata; - - // Set last notification time to 60 seconds ago - setLastBumpNotificationTime(new Date(Date.now() - 60000)); - - await Bump.create({ - messageId: 200n, - userId: ddUser.id, - timestamp: new Date() - }); - clearBumpsCache(); - - await handleBumpStreak( - ddUser, - interaction, - message, - mockClient as unknown as Client - ); - - const calls = mockChannelSend.mock.calls; - const hasLightningMessage = calls.some( - (call) => typeof call[0] === "string" && call[0].includes("⚡") - ); - expect(hasLightningMessage).toBe(false); - }); + clearBumpsCache(); + + await handleBumpStreak( + ddUser, + interaction, + message, + mockClient as unknown as Client, + ); + + const calls = mockChannelSend.mock.calls; + const hasLightningMessage = calls.some( + (call) => typeof call[0] === "string" && call[0].includes("⚡"), + ); + expect(hasLightningMessage).toBe(false); + }); }); describe("sendBumpNotification", () => { - beforeEach(() => { - // Reset notification time - setLastBumpNotificationTime(new Date(0)); - }); - - test("does not send if last bump was less than 2 hours ago", async () => { - // This test would require mocking the config and client.channels.fetch - // For now, we verify the function doesn't throw - const mockClient = createMockClient(); - - // Set lastBumpTime to now (less than 2 hours ago) - // Since lastBumpTime is module-level state, we need to trigger a bump first - // This is a limitation - for full testing, lastBumpTime should be injectable - - // At minimum, verify the function doesn't throw - await expect( - sendBumpNotification(mockClient as unknown as Client) - ).resolves.toBeUndefined(); - }); + beforeEach(() => { + // Reset notification time + setLastBumpNotificationTime(new Date(0)); + }); + + test("does not send if last bump was less than 2 hours ago", async () => { + // This test would require mocking the config and client.channels.fetch + // For now, we verify the function doesn't throw + const mockClient = createMockClient(); + + // Set lastBumpTime to now (less than 2 hours ago) + // Since lastBumpTime is module-level state, we need to trigger a bump first + // This is a limitation - for full testing, lastBumpTime should be injectable + + // At minimum, verify the function doesn't throw + await expect( + sendBumpNotification(mockClient as unknown as Client), + ).resolves.toBeUndefined(); + }); }); diff --git a/src/modules/core/bump.listener.ts b/src/modules/core/bump.listener.ts index acdb1a3..f496570 100644 --- a/src/modules/core/bump.listener.ts +++ b/src/modules/core/bump.listener.ts @@ -20,6 +20,8 @@ import { } from "../../store/models/bumps.js"; import { type DDUser, getOrCreateUserById } from "../../store/models/DDUser.js"; import { fakeMention, mentionIfPingable } from "../../util/users.js"; +import { notifyMultipleAchievements } from "../achievements/achievementNotifier.js"; +import { checkAndAwardAchievements } from "../achievements/achievementService.js"; import type { EventListener } from "../module.js"; /** @@ -172,6 +174,31 @@ export const BumpListener: EventListener = { clearBumpsCache(); await ddUser.save(); await handleBump(client, ddUser, interaction, message); + + // Check and award achievements + try { + const streak = await getBumpStreak(ddUser); + const totalBumps = await ddUser.countBumps(); + const newAchievements = await checkAndAwardAchievements( + ddUser, + { type: "bump", event: "bump_recorded" }, + { totalBumps, bumpStreak: streak.current }, + ); + + if (newAchievements.length > 0) { + const member = await message.guild?.members.fetch(interaction.user.id); + if (member) { + await notifyMultipleAchievements( + client, + member, + newAchievements.map((a) => a.definition), + message.channel, + ); + } + } + } catch (error) { + logger.error("Failed to check bump achievements:", error); + } }, }; const streakReacts: EmojiIdentifierResolvable[] = [ diff --git a/src/modules/moderation/logs.test.ts b/src/modules/moderation/logs.test.ts index 0b719be..7bcc55e 100644 --- a/src/modules/moderation/logs.test.ts +++ b/src/modules/moderation/logs.test.ts @@ -1,6 +1,6 @@ import { describe, expect, mock, test } from "bun:test"; -import { Colors, EmbedBuilder } from "discord.js"; import type { Client, TextChannel, User } from "discord.js"; +import { Colors, type EmbedBuilder } from "discord.js"; import { createMockClient, createMockTextChannel, diff --git a/src/modules/moderation/logs.ts b/src/modules/moderation/logs.ts index a3af352..710bd5a 100644 --- a/src/modules/moderation/logs.ts +++ b/src/modules/moderation/logs.ts @@ -203,7 +203,8 @@ export async function logModerationAction( embed.setColor(embedColors[action.kind]); const targetUser = await client.users.fetch(action.target).catch(() => null); - const targetLabel = action.kind === "ReputationGranted" ? "Recipient" : "Offender"; + const targetLabel = + action.kind === "ReputationGranted" ? "Recipient" : "Offender"; let description = `**${targetLabel}**: ${targetUser && fakeMention(targetUser)} ${actualMention(action.target)}\n`; if ("reason" in action && action.reason) { description += `**Reason**: ${action.reason}\n`; diff --git a/src/modules/moderation/moderation.module.ts b/src/modules/moderation/moderation.module.ts index dadafe4..41fc67b 100644 --- a/src/modules/moderation/moderation.module.ts +++ b/src/modules/moderation/moderation.module.ts @@ -30,5 +30,10 @@ export const ModerationModule: Module = { WordlistCommand, ReputationCommand, ], - listeners: [...InviteListeners, TempBanListener, WarningSchedulerListener, DeletedMessagesListener], + listeners: [ + ...InviteListeners, + TempBanListener, + WarningSchedulerListener, + DeletedMessagesListener, + ], }; diff --git a/src/modules/xp/dailyReward.command.test.ts b/src/modules/xp/dailyReward.command.test.ts index 9c1e76e..43aed72 100644 --- a/src/modules/xp/dailyReward.command.test.ts +++ b/src/modules/xp/dailyReward.command.test.ts @@ -122,9 +122,7 @@ describe("getNextDailyTime", () => { const result = getNextDailyTime(user); expect(result).toBeDefined(); - expect(result?.getTime()).toBe( - lastClaim.getTime() + 1000 * 60 * 60 * 24, - ); + expect(result?.getTime()).toBe(lastClaim.getTime() + 1000 * 60 * 60 * 24); }); }); diff --git a/src/modules/xp/dailyReward.command.ts b/src/modules/xp/dailyReward.command.ts index 364c8cf..75bab81 100644 --- a/src/modules/xp/dailyReward.command.ts +++ b/src/modules/xp/dailyReward.command.ts @@ -12,6 +12,8 @@ import { type DDUser, getOrCreateUserById } from "../../store/models/DDUser.js"; import { createStandardEmbed } from "../../util/embeds.js"; import { isSpecialUser } from "../../util/users.js"; +import { notifyMultipleAchievements } from "../achievements/achievementNotifier.js"; +import { checkAndAwardAchievements } from "../achievements/achievementService.js"; import { scheduleReminder } from "./dailyReward.reminder.js"; import { giveXp } from "./xpForMessage.util.js"; @@ -101,6 +103,31 @@ export const DailyRewardCommand: Command = { if (isSpecialUser(user)) { await scheduleReminder(user.client, user, ddUser); } + + // Check and award achievements + try { + const newAchievements = await checkAndAwardAchievements( + ddUser, + { type: "daily", event: "daily_claimed" }, + { + dailyStreak: Math.max( + ddUser.currentDailyStreak, + ddUser.highestDailyStreak, + ), + }, + ); + + if (newAchievements.length > 0) { + await notifyMultipleAchievements( + user.client, + user, + newAchievements.map((a) => a.definition), + interaction.channel ?? undefined, + ); + } + } catch (error) { + logger.error("Failed to check daily achievements:", error); + } } finally { dailiesInProgress.delete(user.id); } diff --git a/src/modules/xp/dailyReward.reminder.test.ts b/src/modules/xp/dailyReward.reminder.test.ts index 5a0a21a..1d1da94 100644 --- a/src/modules/xp/dailyReward.reminder.test.ts +++ b/src/modules/xp/dailyReward.reminder.test.ts @@ -1,205 +1,219 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { type InstalledClock, install } from "@sinonjs/fake-timers"; import type { Client, GuildMember, Message } from "discord.js"; -import { install, type InstalledClock } from "@sinonjs/fake-timers"; import { clearUserCache, DDUser } from "../../store/models/DDUser.js"; import { getSequelizeInstance, initStorage } from "../../store/storage.js"; import { - createMockClient, - createMockGuildMember, - createMockTextChannel, - createMockUser + createMockClient, + createMockGuildMember, + createMockTextChannel, + createMockUser, } from "../../tests/mocks/discord.js"; -import { scheduledReminders, scheduleReminder } from "./dailyReward.reminder.js"; +import { + scheduledReminders, + scheduleReminder, +} from "./dailyReward.reminder.js"; let clock: InstalledClock; beforeAll(async () => { - await initStorage(); - clock = install(); + await initStorage(); + clock = install(); }); afterAll(() => { - clock.uninstall(); + clock.uninstall(); }); beforeEach(() => { - clock.reset(); - scheduledReminders.clear(); + clock.reset(); + scheduledReminders.clear(); }); afterEach(async () => { - // Cancel all scheduled reminders - for (const job of scheduledReminders.values()) { - job.cancel(); - } - scheduledReminders.clear(); - - await getSequelizeInstance().destroyAll(); - clearUserCache(); + // Cancel all scheduled reminders + for (const job of scheduledReminders.values()) { + job.cancel(); + } + scheduledReminders.clear(); + + await getSequelizeInstance().destroyAll(); + clearUserCache(); }); describe("scheduleReminder", () => { - const createTestContext = () => { - const mockChannelSend = mock(async (_content: unknown) => Promise.resolve({} as Message)); - const mockChannel = createMockTextChannel({ - send: mockChannelSend, - isSendable: () => true - }); - - const channels = new Map(); - // Use the actual bot commands channel ID from config or a test ID - channels.set("bot-commands", mockChannel); - - const mockClient = createMockClient({ channels }); - - const mockUser = createMockUser({ id: "12345" }); - const mockMember = createMockGuildMember({ - id: "12345", - user: mockUser, - client: mockClient, - premiumSince: new Date() // Make them a "special user" (booster) - }); - - return { - mockClient, - mockMember, - mockChannelSend, - mockChannel - }; - }; - - test("does not schedule for user without lastDailyTime", async () => { - const { mockClient, mockMember } = createTestContext(); - - const ddUser = DDUser.build({ - id: BigInt(mockMember.id), - xp: 0n, - level: 0, - bumps: 0, - lastDailyTime: null, - currentDailyStreak: 0, - highestDailyStreak: 0 - }); - - await scheduleReminder( - mockClient as unknown as Client, - mockMember as unknown as GuildMember, - ddUser - ); - - expect(scheduledReminders.size).toBe(0); - }); - - test("does not schedule for user with no streak", async () => { - const { mockClient, mockMember } = createTestContext(); - - // User claimed daily but has no streak (hasn't claimed recently) - const ddUser = DDUser.build({ - id: BigInt(mockMember.id), - xp: 0n, - level: 0, - bumps: 0, - lastDailyTime: new Date(Date.now() - 1000 * 60 * 60 * 72), // 72 hours ago (streak reset) - currentDailyStreak: 0, - highestDailyStreak: 0 - }); - - await scheduleReminder( - mockClient as unknown as Client, - mockMember as unknown as GuildMember, - ddUser - ); - - expect(scheduledReminders.size).toBe(0); - }); - - test("sends immediate reminder if claimable now", async () => { - const { mockClient, mockMember, mockChannelSend } = createTestContext(); - - // User claimed 25 hours ago (can claim now, still has streak) - const ddUser = DDUser.build({ - id: BigInt(mockMember.id), - xp: 0n, - level: 0, - bumps: 0, - lastDailyTime: new Date(Date.now() - 1000 * 60 * 60 * 25), - currentDailyStreak: 5, - highestDailyStreak: 5 - }); - - await scheduleReminder( - mockClient as unknown as Client, - mockMember as unknown as GuildMember, - ddUser - ); - - // Should have sent the reminder immediately instead of scheduling - // The actual send might fail due to missing config, but no job should be scheduled - expect(scheduledReminders.size).toBe(0); - }); - - test("replaces existing reminder when rescheduling", async () => { - const { mockClient, mockMember } = createTestContext(); - - // User with active streak, claimed 10 hours ago - const ddUser = DDUser.build({ - id: BigInt(mockMember.id), - xp: 0n, - level: 0, - bumps: 0, - lastDailyTime: new Date(Date.now() - 1000 * 60 * 60 * 10), - currentDailyStreak: 3, - highestDailyStreak: 3 - }); - - // Schedule first reminder - await scheduleReminder( - mockClient as unknown as Client, - mockMember as unknown as GuildMember, - ddUser - ); - - const firstJobCount = scheduledReminders.size; - - // Schedule again (should replace) - ddUser.lastDailyTime = new Date(Date.now() - 1000 * 60 * 60 * 5); - await scheduleReminder( - mockClient as unknown as Client, - mockMember as unknown as GuildMember, - ddUser - ); - - // Should still only have one job - expect(scheduledReminders.size).toBe(firstJobCount); + const createTestContext = () => { + const mockChannelSend = mock(async (_content: unknown) => + Promise.resolve({} as Message), + ); + const mockChannel = createMockTextChannel({ + send: mockChannelSend, + isSendable: () => true, + }); + + const channels = new Map(); + // Use the actual bot commands channel ID from config or a test ID + channels.set("bot-commands", mockChannel); + + const mockClient = createMockClient({ channels }); + + const mockUser = createMockUser({ id: "12345" }); + const mockMember = createMockGuildMember({ + id: "12345", + user: mockUser, + client: mockClient, + premiumSince: new Date(), // Make them a "special user" (booster) + }); + + return { + mockClient, + mockMember, + mockChannelSend, + mockChannel, + }; + }; + + test("does not schedule for user without lastDailyTime", async () => { + const { mockClient, mockMember } = createTestContext(); + + const ddUser = DDUser.build({ + id: BigInt(mockMember.id), + xp: 0n, + level: 0, + bumps: 0, + lastDailyTime: null, + currentDailyStreak: 0, + highestDailyStreak: 0, + }); + + await scheduleReminder( + mockClient as unknown as Client, + mockMember as unknown as GuildMember, + ddUser, + ); + + expect(scheduledReminders.size).toBe(0); }); - test("schedules job for user with active streak", async () => { - const { mockClient, mockMember } = createTestContext(); - - // User with active streak, claimed 10 hours ago - const ddUser = DDUser.build({ - id: BigInt(mockMember.id), - xp: 0n, - level: 0, - bumps: 0, - lastDailyTime: new Date(Date.now() - 1000 * 60 * 60 * 10), - currentDailyStreak: 5, - highestDailyStreak: 5 - }); - - await scheduleReminder( - mockClient as unknown as Client, - mockMember as unknown as GuildMember, - ddUser - ); - - // Should have scheduled a job - expect(scheduledReminders.has(ddUser.id)).toBe(true); - }); + test("does not schedule for user with no streak", async () => { + const { mockClient, mockMember } = createTestContext(); + + // User claimed daily but has no streak (hasn't claimed recently) + const ddUser = DDUser.build({ + id: BigInt(mockMember.id), + xp: 0n, + level: 0, + bumps: 0, + lastDailyTime: new Date(Date.now() - 1000 * 60 * 60 * 72), // 72 hours ago (streak reset) + currentDailyStreak: 0, + highestDailyStreak: 0, + }); + + await scheduleReminder( + mockClient as unknown as Client, + mockMember as unknown as GuildMember, + ddUser, + ); + + expect(scheduledReminders.size).toBe(0); + }); + + test("sends immediate reminder if claimable now", async () => { + const { mockClient, mockMember, mockChannelSend } = createTestContext(); + + // User claimed 25 hours ago (can claim now, still has streak) + const ddUser = DDUser.build({ + id: BigInt(mockMember.id), + xp: 0n, + level: 0, + bumps: 0, + lastDailyTime: new Date(Date.now() - 1000 * 60 * 60 * 25), + currentDailyStreak: 5, + highestDailyStreak: 5, + }); + + await scheduleReminder( + mockClient as unknown as Client, + mockMember as unknown as GuildMember, + ddUser, + ); + + // Should have sent the reminder immediately instead of scheduling + // The actual send might fail due to missing config, but no job should be scheduled + expect(scheduledReminders.size).toBe(0); + }); + + test("replaces existing reminder when rescheduling", async () => { + const { mockClient, mockMember } = createTestContext(); + + // User with active streak, claimed 10 hours ago + const ddUser = DDUser.build({ + id: BigInt(mockMember.id), + xp: 0n, + level: 0, + bumps: 0, + lastDailyTime: new Date(Date.now() - 1000 * 60 * 60 * 10), + currentDailyStreak: 3, + highestDailyStreak: 3, + }); + + // Schedule first reminder + await scheduleReminder( + mockClient as unknown as Client, + mockMember as unknown as GuildMember, + ddUser, + ); + + const firstJobCount = scheduledReminders.size; + + // Schedule again (should replace) + ddUser.lastDailyTime = new Date(Date.now() - 1000 * 60 * 60 * 5); + await scheduleReminder( + mockClient as unknown as Client, + mockMember as unknown as GuildMember, + ddUser, + ); + + // Should still only have one job + expect(scheduledReminders.size).toBe(firstJobCount); + }); + + test("schedules job for user with active streak", async () => { + const { mockClient, mockMember } = createTestContext(); + + // User with active streak, claimed 10 hours ago + const ddUser = DDUser.build({ + id: BigInt(mockMember.id), + xp: 0n, + level: 0, + bumps: 0, + lastDailyTime: new Date(Date.now() - 1000 * 60 * 60 * 10), + currentDailyStreak: 5, + highestDailyStreak: 5, + }); + + await scheduleReminder( + mockClient as unknown as Client, + mockMember as unknown as GuildMember, + ddUser, + ); + + // Should have scheduled a job + expect(scheduledReminders.has(ddUser.id)).toBe(true); + }); }); describe("scheduledReminders Map", () => { - test("tracks scheduled jobs by user ID", () => { - expect(scheduledReminders).toBeInstanceOf(Map); + test("tracks scheduled jobs by user ID", () => { + expect(scheduledReminders).toBeInstanceOf(Map); }); }); diff --git a/src/modules/xp/xp.listener.ts b/src/modules/xp/xp.listener.ts index 2c27d7d..2a3e84c 100644 --- a/src/modules/xp/xp.listener.ts +++ b/src/modules/xp/xp.listener.ts @@ -2,6 +2,9 @@ import type { Channel } from "discord.js"; import { config } from "../../Config.js"; import { logger } from "../../logging.js"; import { wrapInTransaction } from "../../sentry.js"; +import { getOrCreateUserById } from "../../store/models/DDUser.js"; +import { notifyMultipleAchievements } from "../achievements/achievementNotifier.js"; +import { checkAndAwardAchievements } from "../achievements/achievementService.js"; import type { EventListener } from "../module.js"; import { giveXp, @@ -25,6 +28,27 @@ export const XpListener: EventListener = { logger.debug(`counting message ${msg.id} for XP for ${msg.author.id}`); const xp = xpForMessage(msg.content); await giveXp(author, xp); + + // Check and award XP achievements + try { + const ddUser = await getOrCreateUserById(BigInt(msg.author.id)); + const newAchievements = await checkAndAwardAchievements( + ddUser, + { type: "xp", event: "xp_gained" }, + { totalXp: ddUser.xp, level: ddUser.level }, + ); + + if (newAchievements.length > 0) { + await notifyMultipleAchievements( + msg.client, + author, + newAchievements.map((a) => a.definition), + msg.channel, + ); + } + } catch (error) { + logger.error("Failed to check XP achievements:", error); + } } }), }; diff --git a/src/store/models/DDUser.ts b/src/store/models/DDUser.ts index 01b1d89..7c7989d 100644 --- a/src/store/models/DDUser.ts +++ b/src/store/models/DDUser.ts @@ -10,6 +10,7 @@ import { AllowNull, Attribute, Default, + HasMany, NotNull, PrimaryKey, Table, @@ -17,6 +18,7 @@ import { import { logger } from "../../logging.js"; import { RealBigInt } from "../RealBigInt.js"; import { Bump } from "./Bump.js"; +import { DDUserAchievements } from "./DDUserAchievements.js"; @Table({ tableName: "Users" }) export class DDUser extends Model< @@ -56,6 +58,9 @@ export class DDUser extends Model< @Attribute(DataTypes.DATE) public declare lastReputationUpdate: Date | null; + @HasMany(() => DDUserAchievements, "ddUserId") + public declare ddUserAchievements?: DDUserAchievements[]; + override async save(options?: SaveOptions): Promise { return await Sentry.startSpan( { diff --git a/src/store/models/DDUserAchievements.ts b/src/store/models/DDUserAchievements.ts new file mode 100644 index 0000000..3ece10c --- /dev/null +++ b/src/store/models/DDUserAchievements.ts @@ -0,0 +1,56 @@ +import { + type CreationOptional, + DataTypes, + type InferAttributes, + type InferCreationAttributes, + Model, +} from "@sequelize/core"; +import { + Attribute, + AutoIncrement, + BelongsTo, + ColumnName, + NotNull, + PrimaryKey, + Table, +} from "@sequelize/core/decorators-legacy"; +import { RealBigInt } from "../RealBigInt.js"; +import { DDUser } from "./DDUser.js"; + +@Table({ + tableName: "DDUserAchievements", + paranoid: true, + indexes: [ + { + name: "unique_achievement_ddUserId", + unique: true, + fields: ["achievementId", "ddUserId"], + }, + ], +}) +export class DDUserAchievements extends Model< + InferAttributes, + InferCreationAttributes +> { + @Attribute(DataTypes.INTEGER) + @PrimaryKey + @AutoIncrement + public declare id: CreationOptional; + + @Attribute(DataTypes.STRING) + @NotNull + @ColumnName("achievementId") + public declare achievementId: string; + + @Attribute(RealBigInt) + @NotNull + public declare ddUserId: bigint; + + @BelongsTo(() => DDUser, "ddUserId") + public declare ddUser?: DDUser; + + // Sequelize automatically manages these timestamps with paranoid: true + public declare createdAt: CreationOptional; + public declare updatedAt: CreationOptional; + public declare deletedAt: CreationOptional; +} diff --git a/src/store/models/ModeratorActions.ts b/src/store/models/ModeratorActions.ts index 210bf2e..f8a5816 100644 --- a/src/store/models/ModeratorActions.ts +++ b/src/store/models/ModeratorActions.ts @@ -1,20 +1,20 @@ import { - type CreationOptional, - DataTypes, - type InferAttributes, - type InferCreationAttributes, - Model + type CreationOptional, + DataTypes, + type InferAttributes, + type InferCreationAttributes, + Model, } from "@sequelize/core"; import { - AllowNull, - Attribute, - AutoIncrement, - BelongsTo, - ColumnName, - Default, - NotNull, - PrimaryKey, - Table + AllowNull, + Attribute, + AutoIncrement, + BelongsTo, + ColumnName, + Default, + NotNull, + PrimaryKey, + Table, } from "@sequelize/core/decorators-legacy"; import { RealBigInt } from "../RealBigInt.js"; import { DDUser } from "./DDUser.js"; @@ -30,7 +30,7 @@ export class ModeratorActions extends Model< > { @Attribute(DataTypes.INTEGER) @PrimaryKey - @AutoIncrement + @AutoIncrement public declare id: CreationOptional; @Attribute(RealBigInt) diff --git a/src/store/storage.ts b/src/store/storage.ts index 1cbbbd0..0ed732a 100644 --- a/src/store/storage.ts +++ b/src/store/storage.ts @@ -1,4 +1,8 @@ -import { type AbstractDialect, type DialectName, Sequelize } from "@sequelize/core"; +import { + type AbstractDialect, + type DialectName, + Sequelize, +} from "@sequelize/core"; import { SqliteDialect } from "@sequelize/sqlite3"; import type { ConnectionConfig } from "pg"; import { logger } from "../logging.js"; @@ -6,6 +10,7 @@ import { BlockedWord } from "./models/BlockedWord.js"; import { Bump } from "./models/Bump.js"; import { ColourRoles } from "./models/ColourRoles.js"; import { DDUser } from "./models/DDUser.js"; +import { DDUserAchievements } from "./models/DDUserAchievements.js"; import { FAQ } from "./models/FAQ.js"; import { ModeratorActions } from "./models/ModeratorActions.js"; import { ModMailNote } from "./models/ModMailNote.js"; @@ -31,12 +36,12 @@ function sequelizeLog(sql: string, timing?: number) { let sequelizeInstance: Sequelize | null = null; export async function initStorage() { - // Make idempotent - only initialize once - if (sequelizeInstance) { - return; - } + // Make idempotent - only initialize once + if (sequelizeInstance) { + return; + } - const database = process.env.DDB_DATABASE ?? "database"; + const database = process.env.DDB_DATABASE ?? "database"; const username = process.env.DDB_USERNAME ?? "root"; const password = process.env.DDB_PASSWORD ?? "password"; const host = process.env.DDB_HOST ?? "localhost"; @@ -82,6 +87,7 @@ export async function initStorage() { SuggestionVote, ModMailTicket, ModMailNote, + DDUserAchievements, ThreatLog, ScamDomain, Warning, @@ -106,8 +112,8 @@ export async function initStorage() { } export const getSequelizeInstance = () => { - if (!sequelizeInstance) { - throw new Error("Storage not initialized. Call initStorage() first."); - } + if (!sequelizeInstance) { + throw new Error("Storage not initialized. Call initStorage() first."); + } return sequelizeInstance; };