diff options
| author | Fuwn <[email protected]> | 2025-10-25 18:09:03 -0700 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2025-10-25 20:14:54 -0700 |
| commit | c7354cb00f38aba6fb5f5720477a3eed994837af (patch) | |
| tree | a9de33c5afcf44055d2203798474ec19d30e2f06 /packages/gateway/src/commands/characterClaimUsage.ts | |
| parent | feat(gateway:commands): Add gate to webhook utility commands (diff) | |
| download | umabotdiscord-c7354cb00f38aba6fb5f5720477a3eed994837af.tar.xz umabotdiscord-c7354cb00f38aba6fb5f5720477a3eed994837af.zip | |
feat(gateway:commands): Add character claim usage command
Diffstat (limited to 'packages/gateway/src/commands/characterClaimUsage.ts')
| -rw-r--r-- | packages/gateway/src/commands/characterClaimUsage.ts | 587 |
1 files changed, 587 insertions, 0 deletions
diff --git a/packages/gateway/src/commands/characterClaimUsage.ts b/packages/gateway/src/commands/characterClaimUsage.ts new file mode 100644 index 0000000..1334c4d --- /dev/null +++ b/packages/gateway/src/commands/characterClaimUsage.ts @@ -0,0 +1,587 @@ +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<ReturnType<typeof fetchCharacterList>> | null = + null; + private claimNamesSet: Set<string> = new Set(); + private unclaimNamesSet: Set<string> = new Set(); + private claimNamesMap: Map<string, string> = 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<void> { + 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<EmbedBuilder[]> { + 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<boolean> => { + 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 content = message.content.trim().toLowerCase(); + const claimUsageMatch = content.match(/claim\s+usage\s+(\d+)/); + const claimsMatch = content.match(/claims\s+(\d+)/); + const claimMatch = content.match(/\bclaim\s+(\d+)\b/); + const hasClaim = content.includes("claim"); + const hasClaims = content.includes("claims"); + const hasUsage = content.includes("usage"); + const matchedPattern = + (hasClaim && hasUsage) || hasClaims || (hasClaim && !hasUsage); + + 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; +}; |