import { Message, EmbedBuilder, Colors } from "discord.js"; import { CHARACTER_CLAIM_CATEGORIES_TO_TRACK, CHARACTER_CLAIM_CHANNELS_TO_IGNORE, ROLEPLAY_GUILD_ID, ROLEPLAY_SERVER_STAFF_CATEGORY_IDS, STAFF_ROLES, } from "../../../shared"; import { log, LogLevel } from "../../../shared/log"; import { fetchCharacterList, getCharacterNameFromMessage, } from "./characterClaimParser"; import { replyWithCleanup } from "../utilities"; interface UsageStatistics { characterName: string; originalName: string; messageCount: number; lastUsed: Date | null; ownerMention?: string; } class CharacterClaimUsageTracker { public characterList: Awaited> | null = null; private claimNamesSet: Set = new Set(); private unclaimNamesSet: Set = new Set(); private claimNamesMap: Map = new Map(); private normalizeString(inputString: string): string { return inputString .toLowerCase() .replace(/[^\w\s]/g, "") .replace(/\s+/g, " ") .trim(); } private calculateSimilarity( firstString: string, secondString: string, ): number { const normalizedFirst = this.normalizeString(firstString); const normalizedSecond = this.normalizeString(secondString); const longer = normalizedFirst.length > normalizedSecond.length ? normalizedFirst : normalizedSecond; const shorter = normalizedFirst.length > normalizedSecond.length ? normalizedSecond : normalizedFirst; if (longer.length === 0) return 1.0; const distance = this.levenshteinDistance(longer, shorter); return (longer.length - distance) / longer.length; } private levenshteinDistance( firstString: string, secondString: string, ): number { const matrix = Array(secondString.length + 1) .fill(null) .map(() => Array(firstString.length + 1).fill(null)); for (let i = 0; i <= firstString.length; i++) matrix[0][i] = i; for (let j = 0; j <= secondString.length; j++) matrix[j][0] = j; for (let j = 1; j <= secondString.length; j++) { for (let i = 1; i <= firstString.length; i++) { const cost = firstString[i - 1] === secondString[j - 1] ? 0 : 1; matrix[j][i] = Math.min( matrix[j][i - 1] + 1, matrix[j - 1][i] + 1, matrix[j - 1][i - 1] + cost, ); } } return matrix[secondString.length][firstString.length]; } private findBestMatch(name: string): { match: string | null; similarity: number; originalName: string | null; } { const lowerName = name.toLowerCase(); if (this.claimNamesMap.has(lowerName)) return { match: lowerName, similarity: 1.0, originalName: this.claimNamesMap.get(lowerName)!, }; let bestMatch: string | null = null; let bestSimilarity = 0; let bestOriginal: string | null = null; for (const [claimLower, claimOriginal] of this.claimNamesMap.entries()) { const similarity = this.calculateSimilarity(lowerName, claimLower); if (similarity > bestSimilarity && similarity >= 0.9) { bestSimilarity = similarity; bestMatch = claimLower; bestOriginal = claimOriginal; } } return { match: bestMatch, similarity: bestSimilarity, originalName: bestOriginal, }; } public async initialize(client: any): Promise { this.characterList = await fetchCharacterList(client); this.buildCharacterSets(); } private buildCharacterSets(): void { if (!this.characterList) return; for (const character of this.characterList.claimed) { const lowerName = character.name.toLowerCase(); this.claimNamesSet.add(lowerName); this.claimNamesMap.set(lowerName, character.name); } for (const character of this.characterList.unclaimed) { const lowerName = character.name.toLowerCase(); this.unclaimNamesSet.add(lowerName); this.claimNamesMap.set(lowerName, character.name); } } public async generateUsageReport( message: Message, messagesToAnalyze: number = 50, ): Promise { if (!this.characterList) await this.initialize(message.client); if (!this.characterList) { const errorEmbed = new EmbedBuilder() .setTitle("❌ Error") .setDescription("Failed to initialise character list.") .setColor(0xff0000); return [errorEmbed]; } if (message.guildId !== ROLEPLAY_GUILD_ID) { const errorEmbed = new EmbedBuilder() .setTitle("❌ Error") .setDescription("This command can only be used in the roleplay guild.") .setColor(0xff0000); return [errorEmbed]; } const guild = message.guild; if (!guild) { const errorEmbed = new EmbedBuilder() .setTitle("❌ Error") .setDescription("Failed to fetch guild.") .setColor(0xff0000); return [errorEmbed]; } const clampedMessages = Math.max(1, Math.min(1000, messagesToAnalyze)); const usageData = new Map< string, { count: number; lastUsed: Date | null; originalName?: string; userId?: string; } >(); for (const categoryId of CHARACTER_CLAIM_CATEGORIES_TO_TRACK) { const category = await guild.channels.fetch(categoryId); if (!category || !("children" in category)) continue; for (const [, channel] of category.children.cache) { if ( !channel.isTextBased() || channel.isDMBased() || CHARACTER_CLAIM_CHANNELS_TO_IGNORE.includes(channel.id) ) continue; if (!("messages" in channel) || !("fetch" in channel.messages)) continue; try { let messagesFetched = 0; let lastMessageId: string | undefined; while (messagesFetched < clampedMessages) { const remaining = clampedMessages - messagesFetched; const batchSize = Math.min(100, remaining); const options: any = { limit: batchSize }; if (lastMessageId) options.before = lastMessageId; const messages = (await channel.messages.fetch(options)) as any; if (!messages || messages.size === 0) break; for (const [, message] of messages) { const characterName = await getCharacterNameFromMessage(message); if (!characterName) continue; const lowerName = characterName.toLowerCase(); const match = this.findBestMatch(characterName); if (match.match) { const matchKey = match.match; const existing = usageData.get(matchKey) || { count: 0, lastUsed: null, originalName: match.originalName!, }; existing.count += 1; if (!existing.lastUsed || message.createdAt > existing.lastUsed) existing.lastUsed = message.createdAt; usageData.set(matchKey, existing); } else { const existing = usageData.get(lowerName) || { count: 0, lastUsed: null, originalName: characterName, userId: message.author.id, }; existing.count += 1; if (!existing.lastUsed || message.createdAt > existing.lastUsed) existing.lastUsed = message.createdAt; usageData.set(lowerName, existing); } } messagesFetched += messages.size; lastMessageId = messages.last()?.id; if (messages.size < batchSize) break; } if ("threads" in channel && channel.isTextBased()) { try { const threads = await channel.threads.fetchActive(); for (const [, thread] of threads.threads) { try { let messagesFetched = 0; let lastMessageId: string | undefined; while (messagesFetched < clampedMessages) { const remaining = clampedMessages - messagesFetched; const batchSize = Math.min(100, remaining); const options: any = { limit: batchSize }; if (lastMessageId) options.before = lastMessageId; const messages = (await thread.messages.fetch( options, )) as any; if (!messages || messages.size === 0) break; for (const [, message] of messages) { const characterName = await getCharacterNameFromMessage(message); if (!characterName) continue; const lowerName = characterName.toLowerCase(); const match = this.findBestMatch(characterName); if (match.match) { const matchKey = match.match; const existing = usageData.get(matchKey) || { count: 0, lastUsed: null, originalName: match.originalName!, }; existing.count += 1; if ( !existing.lastUsed || message.createdAt > existing.lastUsed ) existing.lastUsed = message.createdAt; usageData.set(matchKey, existing); } else { const existing = usageData.get(lowerName) || { count: 0, lastUsed: null, originalName: characterName, userId: message.author.id, }; existing.count += 1; if ( !existing.lastUsed || message.createdAt > existing.lastUsed ) existing.lastUsed = message.createdAt; usageData.set(lowerName, existing); } } messagesFetched += messages.size; lastMessageId = messages.last()?.id; if (messages.size < batchSize) break; } } catch (error) { log( `Failed to analyse thread ${thread.id}: ${error}`, LogLevel.Error, ); } } } catch (error) { log( `Failed to fetch threads for channel ${channel.id}: ${error}`, LogLevel.Error, ); } } } catch (error) { log( `Failed to analyse channel ${channel.id}: ${error}`, LogLevel.Error, ); } } } const claimedStatistics: UsageStatistics[] = []; for (const character of this.characterList.claimed) { const lowerName = character.name.toLowerCase(); const data = usageData.get(lowerName); claimedStatistics.push({ characterName: character.name, originalName: character.name, messageCount: data?.count ?? 0, lastUsed: data?.lastUsed ?? null, ownerMention: character.mention, }); } claimedStatistics.sort((a, b) => { if (a.messageCount === 0 && b.messageCount === 0) return 0; if (a.messageCount === 0) return -1; if (b.messageCount === 0) return 1; return a.messageCount - b.messageCount; }); const embeds: EmbedBuilder[] = []; const activeClaimed = claimedStatistics.filter((s) => s.messageCount > 0); const inactiveClaimed = claimedStatistics.filter( (s) => s.messageCount === 0, ); const mainEmbed = new EmbedBuilder() .setTitle("📊 Character Claim Usage Report") .setDescription(`Analysing last ${clampedMessages} messages per channel`) .setColor(Colors.Blurple) .setTimestamp(); let activeText = ""; if (activeClaimed.length === 0) { activeText = "• No claimed characters have chatted"; } else { for (const stat of activeClaimed) { const lastUsed = stat.lastUsed ? stat.lastUsed.toLocaleDateString() : "Never"; const ownerInfo = stat.ownerMention ? ` (<@${stat.ownerMention}>)` : ""; activeText += `${stat.characterName}${ownerInfo}: ${stat.messageCount} messages (last: ${lastUsed})\n`; } } const truncatedActiveText = activeText && activeText.length > 1024 ? `${activeText.substring(0, 1000)} ... (truncated)` : activeText || "None"; mainEmbed.addFields({ name: `✅ Active Claimed Characters (${activeClaimed.length})`, value: truncatedActiveText, inline: false, }); embeds.push(mainEmbed); let inactiveText = ""; if (inactiveClaimed.length === 0) { inactiveText = "All claimed characters have chatted!"; } else { for (const stat of inactiveClaimed) { const ownerInfo = stat.ownerMention ? ` (<@${stat.ownerMention}>)` : ""; inactiveText += `${stat.characterName}${ownerInfo}\n`; } } const inactiveEmbed = new EmbedBuilder() .setTitle(`❌ Inactive Claimed Characters (${inactiveClaimed.length})`) .setColor(0xff4444) .setTimestamp(); if (inactiveText.length > 1024) { const chunks = inactiveText.match(/.{1,1000}/g) || []; for (let i = 0; i < chunks.length; i++) inactiveEmbed.addFields({ name: `Inactive Characters (${i + 1}/${chunks.length})`, value: chunks[i], inline: false, }); } else { inactiveEmbed.addFields({ name: "Characters", value: inactiveText || "None", inline: false, }); } embeds.push(inactiveEmbed); return embeds; } } export const characterClaimUsageTracker = new CharacterClaimUsageTracker(); export const handleCharacterClaimUsageCommand = async ( message: Message, ): Promise => { if (message.author.bot) return false; const isBotMentioned = message.mentions.has(message.client.user!.id); if (!isBotMentioned) return false; const application = await message.client.application?.fetch(); const ownerId = application?.owner?.id; const isBotOwner = message.author.id === ownerId; if (!isBotOwner && message.member) { const hasStaffRole = message.member.roles.cache.some((role) => STAFF_ROLES.includes(role.id as any), ); if (!hasStaffRole) return false; } else if (!isBotOwner) { return false; } const botMentionRegex = new RegExp(`<@!?${message.client.user!.id}>`); const contentAfterMention = message.content .replace(botMentionRegex, "") .trim(); const content = contentAfterMention.toLowerCase(); const claimUsageMatch = content.match(/^claim\s+usage\s+(\d+)/); const claimsMatch = content.match(/^claims\s+(\d+)/); const claimMatch = content.match(/^claim\s+(\d+)/); const matchedPattern = claimUsageMatch || claimsMatch || claimMatch || content === "claim usage" || content === "claims" || content === "claim"; if (!matchedPattern) return false; let messagesToAnalyze = 50; if (claimUsageMatch) { messagesToAnalyze = parseInt(claimUsageMatch[1], 10); } else if (claimsMatch) { messagesToAnalyze = parseInt(claimsMatch[1], 10); } else if (claimMatch) { messagesToAnalyze = parseInt(claimMatch[1], 10); } const channel = message.channel; if (channel.isDMBased()) return false; let isInStaffChannel = false; if (channel.isTextBased() && !channel.isThread()) { const textChannel = channel as any; const parentId = textChannel.parentId; if (parentId && ROLEPLAY_SERVER_STAFF_CATEGORY_IDS.includes(parentId)) isInStaffChannel = true; } else if (channel.isThread()) { const thread = channel as any; const parentId = thread.parentId; if (parentId && ROLEPLAY_SERVER_STAFF_CATEGORY_IDS.includes(parentId)) isInStaffChannel = true; } if (!isInStaffChannel) { await message.react("❌"); await replyWithCleanup( message, "❌ This command can only be used in staff channels.", 5000, ); return true; } await channel.sendTyping(); const typingInterval = setInterval(() => { channel.sendTyping().catch(() => {}); }, 9500); try { if (!characterClaimUsageTracker.characterList) await characterClaimUsageTracker.initialize(message.client); const embeds = await characterClaimUsageTracker.generateUsageReport( message, messagesToAnalyze, ); clearInterval(typingInterval); if (embeds.length > 10) { for (let i = 0; i < embeds.length; i += 10) { const chunk = embeds.slice(i, i + 10); await channel.send({ embeds: chunk }); } } else { await channel.send({ embeds }); } } catch (error) { clearInterval(typingInterval); log(`Failed to generate usage report: ${error}`, LogLevel.Error); const errorDetails = error instanceof Error ? error.stack : String(error); log(`Error details: ${errorDetails}`, LogLevel.Error); try { await channel.send( "❌ Failed to generate usage report. Check logs for details.", ); } catch (sendError) { log(`Failed to send error message: ${sendError}`, LogLevel.Error); } } return true; };