From dbe6fdb3ef486b9b3f937079ab6734fb05cfe591 Mon Sep 17 00:00:00 2001 From: noelhermanss <79139881+noelhermanss@users.noreply.github.com> Date: Sun, 12 Apr 2026 14:00:02 +0200 Subject: [PATCH 1/2] Add /swap command to swap player stats Introduce a new slash command /swap that swaps all stats between two player licenses owned by the same person. RWFC mods should verify the following two things before executing the command: 1. Both licenses are on the VR leaderboard (important for fresh licenses) 2. Both licenses are verified to be owned by the same person --- commands/swap.ts | 93 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 commands/swap.ts diff --git a/commands/swap.ts b/commands/swap.ts new file mode 100644 index 0000000..ec6e677 --- /dev/null +++ b/commands/swap.ts @@ -0,0 +1,93 @@ +import { CacheType, ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; +import { pidToFc, resolveModRestrictPermission, resolvePidFromString, validateID } from "../utils.js"; +import { getConfig } from "../config.js"; + +const config = getConfig(); + +export default { + modOnly: true, + adminOnly: false, + + data: new SlashCommandBuilder() + .setName("swap") + .setDescription("Swap all stats between two player licenses owned by the same person") + .addStringOption(option => option.setName("source-id") + .setDescription("friend code or pid of the first player") + .setRequired(true)) + .addStringOption(option => option.setName("target-id") + .setDescription("friend code or pid of the second player") + .setRequired(true)) + .addStringOption(option => option.setName("reason") + .setDescription("reason for performing this swap") + .setRequired(true)) + .setDefaultMemberPermissions(resolveModRestrictPermission()), + + exec: async function(interaction: ChatInputCommandInteraction) { + let sourceId = interaction.options.getString("source-id", true).trim(); + let targetId = interaction.options.getString("target-id", true).trim(); + const reason = interaction.options.getString("reason", true); + const moderator = interaction.user.id; + + const [validSource, errSource] = validateID(sourceId); + if (!validSource) { + await interaction.reply({ content: `Error swapping: invalid source ID "${sourceId}": ${errSource}` }); + return; + } + + const [validTarget, errTarget] = validateID(targetId); + if (!validTarget) { + await interaction.reply({ content: `Error swapping: invalid target ID "${targetId}": ${errTarget}` }); + return; + } + + const sourcePid = resolvePidFromString(sourceId); + const targetPid = resolvePidFromString(targetId); + + if (sourcePid === targetPid) { + await interaction.reply({ content: "Error swapping: source and target must be different players." }); + return; + } + + const sourceFc = pidToFc(sourcePid); + const targetFc = pidToFc(targetPid); + + const leaderboardUrl = `http://${config.leaderboardServer}:${config.leaderboardPort}`; + try { + const leaderboardResponse = await fetch(`${leaderboardUrl}/api/moderation/swap`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${config.wfcSecret}` + }, + body: JSON.stringify({ + sourcePid: sourcePid.toString(), + targetPid: targetPid.toString(), + moderator: moderator, + reason: reason + }) + }); + + if (leaderboardResponse.ok) { + await interaction.reply({ + content: `Successfully swapped stats between "${sourceFc}" and "${targetFc}".` + }); + console.log(`Successfully swapped stats between ${sourcePid} and ${targetPid} for reason: ${reason}`); + } + else { + const errorText = await leaderboardResponse.text(); + console.error(`Failed to swap players ${sourcePid} <-> ${targetPid}: ${leaderboardResponse.status}`); + console.error(`Error details: ${errorText}`); + + await interaction.reply({ + content: `Failed to swap "${sourceFc}" and "${targetFc}": error ${leaderboardResponse.status}` + }); + } + } + catch (error) { + console.error(`Error calling leaderboard API for swap ${sourcePid} <-> ${targetPid}:`, error); + await interaction.reply({ + content: `Failed to swap "${sourceFc}" and "${targetFc}": network error` + }); + } + } +}; From f0b4eb9169390711ee6d56af5dd71f875b414e35 Mon Sep 17 00:00:00 2001 From: noelhermanss <79139881+noelhermanss@users.noreply.github.com> Date: Sun, 12 Apr 2026 14:09:48 +0200 Subject: [PATCH 2/2] Log moderator actions Sends notifications to the private logs channel when moderators perform flag, unflag, or swap actions. Please verify if this is the correct way to only send it to the private logs channel and not the public one. --- commands/flag.ts | 21 ++++++++++++++++++--- commands/swap.ts | 22 +++++++++++++++++++--- commands/unflag.ts | 21 ++++++++++++++++++--- 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/commands/flag.ts b/commands/flag.ts index a8233c0..c4e079e 100644 --- a/commands/flag.ts +++ b/commands/flag.ts @@ -1,6 +1,6 @@ -import { CacheType, ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; -import { pidToFc, resolveModRestrictPermission, resolvePidFromString, validateID } from "../utils.js"; -import { getConfig } from "../config.js"; +import { CacheType, ChatInputCommandInteraction, EmbedBuilder, GuildMember, SlashCommandBuilder } from "discord.js"; +import { getChannels, getConfig } from "../config.js"; +import { getColor, getMiiImageURL, pidToFc, resolveModRestrictPermission, resolvePidFromString, validateID } from "../utils.js"; const config = getConfig(); @@ -51,6 +51,21 @@ export default { }); if (leaderboardResponse.ok) { + const member = interaction.member as GuildMember | null; + + const embed = new EmbedBuilder() + .setColor(getColor()) + .setTitle(`Flag performed by ${member?.displayName ?? "Unknown"}`) + .setThumbnail(getMiiImageURL(fc)) + .addFields( + { name: "Server", value: interaction.guild!.name }, + { name: "Moderator", value: `<@${member?.id ?? "Unknown"}>` }, + { name: "Friend Code", value: fc }, + { name: "Reason", value: reason }, + ) + .setTimestamp(); + + await getChannels().logs.send({ embeds: [embed] }); await interaction.reply({ content: `Successfully flagged player with friend code "${fc}" as suspicious.` }); diff --git a/commands/swap.ts b/commands/swap.ts index ec6e677..25cd1a7 100644 --- a/commands/swap.ts +++ b/commands/swap.ts @@ -1,6 +1,6 @@ -import { CacheType, ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; -import { pidToFc, resolveModRestrictPermission, resolvePidFromString, validateID } from "../utils.js"; -import { getConfig } from "../config.js"; +import { CacheType, ChatInputCommandInteraction, EmbedBuilder, GuildMember, SlashCommandBuilder } from "discord.js"; +import { getChannels, getConfig } from "../config.js"; +import { getColor, getMiiImageURL, pidToFc, resolveModRestrictPermission, resolvePidFromString, validateID } from "../utils.js"; const config = getConfig(); @@ -68,6 +68,22 @@ export default { }); if (leaderboardResponse.ok) { + const member = interaction.member as GuildMember | null; + + const embed = new EmbedBuilder() + .setColor(getColor()) + .setTitle(`Swap performed by ${member?.displayName ?? "Unknown"}`) + .setThumbnail(getMiiImageURL(sourceFc)) + .addFields( + { name: "Server", value: interaction.guild!.name }, + { name: "Moderator", value: `<@${member?.id ?? "Unknown"}>` }, + { name: "Source FC", value: sourceFc }, + { name: "Target FC", value: targetFc }, + { name: "Reason", value: reason }, + ) + .setTimestamp(); + + await getChannels().logs.send({ embeds: [embed] }); await interaction.reply({ content: `Successfully swapped stats between "${sourceFc}" and "${targetFc}".` }); diff --git a/commands/unflag.ts b/commands/unflag.ts index dab1336..ff1ec36 100644 --- a/commands/unflag.ts +++ b/commands/unflag.ts @@ -1,6 +1,6 @@ -import { CacheType, ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; -import { pidToFc, resolveModRestrictPermission, resolvePidFromString, validateID } from "../utils.js"; -import { getConfig } from "../config.js"; +import { CacheType, ChatInputCommandInteraction, EmbedBuilder, GuildMember, SlashCommandBuilder } from "discord.js"; +import { getChannels, getConfig } from "../config.js"; +import { getColor, getMiiImageURL, pidToFc, resolveModRestrictPermission, resolvePidFromString, validateID } from "../utils.js"; const config = getConfig(); @@ -51,6 +51,21 @@ export default { }); if (leaderboardResponse.ok) { + const member = interaction.member as GuildMember | null; + + const embed = new EmbedBuilder() + .setColor(getColor()) + .setTitle(`Unflag performed by ${member?.displayName ?? "Unknown"}`) + .setThumbnail(getMiiImageURL(fc)) + .addFields( + { name: "Server", value: interaction.guild!.name }, + { name: "Moderator", value: `<@${member?.id ?? "Unknown"}>` }, + { name: "Friend Code", value: fc }, + { name: "Reason", value: reason }, + ) + .setTimestamp(); + + await getChannels().logs.send({ embeds: [embed] }); await interaction.reply({ content: `Successfully removed suspicious flag from player with friend code "${fc}"` });