summaryrefslogtreecommitdiff
path: root/packages/gateway/src/commands/characterClaimUsage.ts
diff options
context:
space:
mode:
authorFuwn <[email protected]>2025-10-25 18:09:03 -0700
committerFuwn <[email protected]>2025-10-25 20:14:54 -0700
commitc7354cb00f38aba6fb5f5720477a3eed994837af (patch)
treea9de33c5afcf44055d2203798474ec19d30e2f06 /packages/gateway/src/commands/characterClaimUsage.ts
parentfeat(gateway:commands): Add gate to webhook utility commands (diff)
downloadumabotdiscord-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.ts587
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;
+};