Conversation
There was a problem hiding this comment.
Pull request overview
This pull request adds emoji icons to skin tier displays in the skin command. However, it also includes several unrelated changes that should be in separate PRs.
Changes:
- Adds
tierEmotesobject with Discord custom emoji IDs for each skin tier (Select, Deluxe, Premium, Exclusive, Ultra) - Introduces new
getTier()function that returns tier name with emoji - Updates
src/embeds/skin.jsto usegetTier()instead ofgetTierName() - Includes unrelated changes: new team_standings command, Discord.js event name fix, and documentation file
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/utils/tiers.js | Adds tierEmotes object and getTier() function to include emojis with tier names |
| src/embeds/skin.js | Updates import and usage from getTierName to getTier to display emojis |
| src/commands/liquipedia/team_standings.js | Completely new command for team standings - unrelated to PR purpose |
| index.js | Updates Discord.js event name from 'ready' to 'clientReady' - unrelated to PR purpose |
| CLAUDE.md | Adds new documentation file - unrelated to PR purpose |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
CLAUDE.md
Outdated
| # CLAUDE.md | ||
|
|
||
| This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. | ||
|
|
||
| ## Commands | ||
|
|
||
| ```bash | ||
| npm install # Install dependencies | ||
| npm start # Run the bot (node index.js) | ||
| ``` | ||
|
|
||
| Set `NODE_ENV=development` to use dev bot token and register commands to a single guild (faster for testing). | ||
|
|
||
| ## Environment Variables | ||
|
|
||
| - `DISCORD_TOKEN` / `DEV_DISCORD_TOKEN` - Bot tokens for production/development | ||
| - `CLIENT_ID` / `DEV_CLIENT_ID` - Discord application client IDs | ||
| - `GUILD_ID` - Required for dev mode; also used to clear guild commands in production | ||
| - `LIQUIPEDIA_API_KEY` - Required for `/player`, `/team`, `/upcoming_matches`, `/upcoming_tournaments` commands | ||
|
|
||
| ## Architecture | ||
|
|
||
| **Discord.js v14 bot using ES modules** (`"type": "module"` in package.json). | ||
|
|
||
| ### Entry Point | ||
| `index.js` - Dynamically loads all commands from `src/commands/` (including subdirectories), registers slash commands with Discord, and handles interactions. | ||
|
|
||
| ### Project Structure | ||
| - `src/commands/` - Slash command handlers. Each file exports `{ data: SlashCommandBuilder, execute: function }`. | ||
| - `src/commands/liquipedia/` - Commands that use the Liquipedia API (player, team, matches, tournaments). | ||
| - `src/embeds/` - Reusable embed builders (e.g., `createAgentEmbed`, `createSkinEmbed`). | ||
| - `src/utils/` - Helpers for API fetching, caching, and data mapping. | ||
|
|
||
| ### Data Sources | ||
| - **Valorant API** (`https://valorant-api.com/v1/`) - Agents, weapons, skins, bundles, maps, events, seasons. | ||
| - **Liquipedia API** - Esports data (players, teams, tournaments). | ||
|
|
||
| ### Global State | ||
| `src/utils/colors.js` sets `globalThis.VALORANT_RED` which is used throughout embeds. | ||
|
|
||
| ### Caching | ||
| `src/utils/cache.js` provides a simple in-memory cache with TTL for Liquipedia responses. | ||
|
|
||
| ### Skin Tier System | ||
| `src/utils/tiers.js` maps skin tier UUIDs to names (Select, Deluxe, Premium, Exclusive, Ultra) and prices. |
There was a problem hiding this comment.
This new documentation file appears unrelated to the stated PR purpose of adding tier icons to the skin command. While documentation is valuable, this should be in a separate PR for better code organization and review.
src/utils/tiers.js
Outdated
| console.log('emote', emote); | ||
| return `${name} ${emote}`.trim(); | ||
| } | ||
|
|
There was a problem hiding this comment.
Remove this console.log debugging statement before merging. Debug logs should not be left in production code.
| console.log('emote', emote); | |
| return `${name} ${emote}`.trim(); | |
| } | |
| return `${name} ${emote}`.trim(); | |
| } |
src/utils/tiers.js
Outdated
| "Select": "<:select:1464237592022351932>", | ||
| "Deluxe": "<:deluxe:1464237687543435297>", | ||
| "Premium": "<:premium:1464237631079583858>", | ||
| "Exclusive": "<:exclusive:1464237592022351932>", |
There was a problem hiding this comment.
Both "Select" and "Exclusive" tiers are using the same emoji ID (1464237592022351932). This appears to be a copy-paste error. Each tier should have its own unique emoji to properly distinguish them visually.
| "Exclusive": "<:exclusive:1464237592022351932>", | |
| "Exclusive": "<:exclusive:1464237799999999999>", |
| import { SlashCommandBuilder, EmbedBuilder } from 'discord.js'; | ||
| import fetch from 'node-fetch'; | ||
|
|
||
| import { getCache, setCache } from '../../utils/cache.js'; | ||
|
|
||
| // /team_standings #{team-name} | ||
| // | ||
| // Look up a Valorant team's wins and losses in their current or most recent tournament | ||
|
|
||
| export default { | ||
| data: new SlashCommandBuilder() | ||
| .setName('team_standings') | ||
| .setDescription('Look up a team\'s wins and losses in their current or most recent tournament') | ||
| .addStringOption(option => | ||
| option.setName('team') | ||
| .setDescription('The name of the team') | ||
| .setRequired(true) | ||
| ), | ||
|
|
||
| async execute(interaction) { | ||
| await interaction.deferReply(); | ||
|
|
||
| const teamName = interaction.options.getString('team'); | ||
|
|
||
| // Check cache first | ||
| const cacheKey = `team-standings-${teamName.toLowerCase()}`; | ||
| const cachedData = getCache(cacheKey); | ||
| if (cachedData) { | ||
| await interaction.editReply(cachedData); | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| // First, find the team to get the correct pagename | ||
| const team = await fetchTeam(teamName); | ||
| if (!team) { | ||
| await interaction.editReply(`Team "${teamName}" not found. Please check the name and try again.`); | ||
| return; | ||
| } | ||
|
|
||
| // Fetch recent matches for this team | ||
| const matches = await fetchTeamMatches(team.pagename, team.name); | ||
|
|
||
| if (!matches || matches.length === 0) { | ||
| await interaction.editReply(`No recent tournament matches found for "${team.name}".`); | ||
| return; | ||
| } | ||
|
|
||
| // Group matches by tournament and find the most recent one | ||
| const tournamentData = groupMatchesByTournament(matches, team.pagename, team.name); | ||
|
|
||
| if (!tournamentData) { | ||
| await interaction.editReply(`No tournament data found for "${team.name}".`); | ||
| return; | ||
| } | ||
|
|
||
| const { tournament, wins, losses, isOngoing, matches: tournamentMatches } = tournamentData; | ||
| const teamLogo = team.logourl || team.logodarkurl || team.textlesslogourl || ''; | ||
|
|
||
| // Build the embed | ||
| const embed = new EmbedBuilder() | ||
| .setTitle(`${team.name} - Tournament Standings`) | ||
| .setColor(globalThis.VALORANT_RED) | ||
| .setDescription(buildDescription(tournament, isOngoing)) | ||
| .setFooter({ text: 'Data source: Liquipedia', iconURL: 'https://liquipedia.net/commons/images/2/2c/Liquipedia_logo.png' }); | ||
|
|
||
| // Add record field | ||
| const record = `**${wins}** - **${losses}**`; | ||
| const winRate = wins + losses > 0 ? ((wins / (wins + losses)) * 100).toFixed(1) : '0.0'; | ||
|
|
||
| embed.addFields( | ||
| { name: 'Record (W-L)', value: record, inline: true }, | ||
| { name: 'Win Rate', value: `${winRate}%`, inline: true }, | ||
| { name: 'Matches Played', value: `${wins + losses}`, inline: true } | ||
| ); | ||
|
|
||
| // Add recent match results (up to 5) | ||
| const recentResults = buildRecentResults(tournamentMatches, team.pagename, team.name); | ||
| if (recentResults) { | ||
| embed.addFields({ name: 'Recent Results', value: recentResults, inline: false }); | ||
| } | ||
|
|
||
| if (teamLogo) { | ||
| embed.setThumbnail(teamLogo); | ||
| } | ||
|
|
||
| const replyPayload = { embeds: [embed] }; | ||
|
|
||
| // Cache for 15 minutes (standings change more frequently) | ||
| setCache(cacheKey, replyPayload, 1000 * 60 * 15); | ||
|
|
||
| await interaction.editReply(replyPayload); | ||
| } catch (error) { | ||
| console.error(error); | ||
| await interaction.editReply('There was an error while fetching team standings. Please try again later.'); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Fetch team by name | ||
| async function fetchTeam(teamName) { | ||
| const response = await fetch(`https://api.liquipedia.net/api/v3/team?wiki=valorant&conditions=%5B%5Bname%3A%3A${encodeURIComponent(teamName)}%5D%5D`, { | ||
| headers: { | ||
| 'accept': 'application/json', | ||
| 'authorization': `Apikey ${process.env.LIQUIPEDIA_API_KEY}` | ||
| } | ||
| }); | ||
| const data = await response.json(); | ||
|
|
||
| if (!data || !data.result || data.result.length === 0) { | ||
| return null; | ||
| } | ||
|
|
||
| return data.result[0]; | ||
| } | ||
|
|
||
| // Fetch recent matches for a team (last 3 months) | ||
| async function fetchTeamMatches(teamPagename, teamName) { | ||
| const threeMonthsAgo = new Date(); | ||
| threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); | ||
| const dateStr = threeMonthsAgo.toISOString().split('T')[0]; | ||
|
|
||
| // Fetch only FINISHED matches - these have resolved team names | ||
| // Unfinished matches often have placeholders like "#6 Seed" | ||
| const conditions = encodeURIComponent(`[[date::>${dateStr}]] AND [[finished::1]]`); | ||
|
|
||
| const response = await fetch(`https://api.liquipedia.net/api/v3/match?wiki=valorant&conditions=${conditions}&limit=500&order=date%20DESC`, { | ||
| headers: { | ||
| 'accept': 'application/json', | ||
| 'authorization': `Apikey ${process.env.LIQUIPEDIA_API_KEY}` | ||
| } | ||
| }); | ||
| const data = await response.json(); | ||
|
|
||
| if (!data || !data.result) { | ||
| return null; | ||
| } | ||
|
|
||
| // Filter matches to only include those with the team (exact match) | ||
| const teamMatches = data.result.filter(match => { | ||
| if (!match.match2opponents || match.match2opponents.length < 2) return false; | ||
|
|
||
| const team1Name = match.match2opponents[0]?.name?.toLowerCase() || ''; | ||
| const team2Name = match.match2opponents[1]?.name?.toLowerCase() || ''; | ||
| const searchName = teamName.toLowerCase(); | ||
| const searchPagename = teamPagename.toLowerCase(); | ||
|
|
||
| // Use exact matching to avoid false positives | ||
| return team1Name === searchName || team2Name === searchName || | ||
| team1Name === searchPagename || team2Name === searchPagename; | ||
| }); | ||
|
|
||
| return teamMatches; | ||
| } | ||
|
|
||
| // Group matches by tournament and determine wins/losses | ||
| function groupMatchesByTournament(matches, teamPagename, teamName) { | ||
| if (!matches || matches.length === 0) return null; | ||
|
|
||
| // Helper to check if a team name matches | ||
| const isTeamMatch = (name) => { | ||
| if (!name) return false; | ||
| const lowerName = name.toLowerCase(); | ||
| return lowerName === teamPagename.toLowerCase() || lowerName === teamName.toLowerCase(); | ||
| }; | ||
|
|
||
| // Group matches by tournament | ||
| const tournaments = {}; | ||
| const now = new Date(); | ||
|
|
||
| for (const match of matches) { | ||
| // Skip matches without valid opponents | ||
| if (!match.match2opponents || match.match2opponents.length < 2) continue; | ||
|
|
||
| // Only include matches where the team participated | ||
| const team1Name = match.match2opponents[0]?.name || ''; | ||
| const team2Name = match.match2opponents[1]?.name || ''; | ||
| if (!isTeamMatch(team1Name) && !isTeamMatch(team2Name)) continue; | ||
|
|
||
| const tournamentName = match.tournament || match.pagename || 'Unknown Tournament'; | ||
|
|
||
| if (!tournaments[tournamentName]) { | ||
| tournaments[tournamentName] = { | ||
| name: tournamentName, | ||
| matches: [], | ||
| latestDate: null, | ||
| hasUpcoming: false | ||
| }; | ||
| } | ||
|
|
||
| const matchDate = match.date ? new Date(match.date) : null; | ||
|
|
||
| // Track if tournament has upcoming matches | ||
| if (matchDate && matchDate > now) { | ||
| tournaments[tournamentName].hasUpcoming = true; | ||
| } | ||
|
|
||
| // Track the latest match date for this tournament | ||
| if (matchDate && (!tournaments[tournamentName].latestDate || matchDate > tournaments[tournamentName].latestDate)) { | ||
| tournaments[tournamentName].latestDate = matchDate; | ||
| } | ||
|
|
||
| tournaments[tournamentName].matches.push(match); | ||
| } | ||
|
|
||
| // Find the most recent tournament | ||
| let selectedTournament = null; | ||
|
|
||
| for (const data of Object.values(tournaments)) { | ||
| if (data.matches.length > 0) { | ||
| if (!selectedTournament || data.latestDate > selectedTournament.latestDate) { | ||
| selectedTournament = data; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (!selectedTournament) return null; | ||
|
|
||
| // Determine if tournament is "ongoing" - if last match was within the past 14 days | ||
| const twoWeeksAgo = new Date(); | ||
| twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14); | ||
| const isOngoing = selectedTournament.latestDate && selectedTournament.latestDate > twoWeeksAgo; | ||
|
|
||
| // Calculate wins and losses | ||
| let wins = 0; | ||
| let losses = 0; | ||
|
|
||
| for (const match of selectedTournament.matches) { | ||
| const team1 = match.match2opponents[0]; | ||
| const team2 = match.match2opponents[1]; | ||
|
|
||
| const isTeam1 = isTeamMatch(team1?.name); | ||
| const isTeam2 = isTeamMatch(team2?.name); | ||
|
|
||
| if (!isTeam1 && !isTeam2) continue; | ||
|
|
||
| // Check if match is finished | ||
| const isFinished = match.finished === '1' || match.finished === 1 || match.winner; | ||
|
|
||
| if (!isFinished) continue; | ||
|
|
||
| // Determine winner based on match2opponents scores or status | ||
| let teamWon = false; | ||
|
|
||
| if (match.winner !== undefined && match.winner !== null) { | ||
| if (isTeam1 && (match.winner === 1 || match.winner === '1')) teamWon = true; | ||
| if (isTeam2 && (match.winner === 2 || match.winner === '2')) teamWon = true; | ||
| } else { | ||
| // Compare scores from match2opponents | ||
| const score1 = parseInt(team1?.score) || 0; | ||
| const score2 = parseInt(team2?.score) || 0; | ||
|
|
||
| if (isTeam1 && score1 > score2) teamWon = true; | ||
| if (isTeam2 && score2 > score1) teamWon = true; | ||
| } | ||
|
|
||
| if (teamWon) { | ||
| wins++; | ||
| } else { | ||
| losses++; | ||
| } | ||
| } | ||
|
|
||
| return { | ||
| tournament: selectedTournament.name, | ||
| wins, | ||
| losses, | ||
| isOngoing, | ||
| matches: selectedTournament.matches | ||
| }; | ||
| } | ||
|
|
||
| // Build description with tournament info | ||
| function buildDescription(tournamentName, isOngoing) { | ||
| const cleanName = formatTournamentName(tournamentName); | ||
| const tournamentLink = `[${cleanName}](https://liquipedia.net/valorant/${encodeURIComponent(tournamentName)})`; | ||
|
|
||
| if (isOngoing) { | ||
| return `**Current Tournament:** ${tournamentLink}`; | ||
| } else { | ||
| return `**No ongoing tournament**\nShowing results from: ${tournamentLink}`; | ||
| } | ||
| } | ||
|
|
||
| // Format tournament name for display | ||
| function formatTournamentName(pagename) { | ||
| if (!pagename) return 'Unknown Tournament'; | ||
|
|
||
| // Replace underscores with spaces and clean up | ||
| return pagename | ||
| .replace(/_/g, ' ') | ||
| .replace(/\//g, ' - '); | ||
| } | ||
|
|
||
| // Build recent results string | ||
| function buildRecentResults(matches, teamPagename, teamName) { | ||
| if (!matches || matches.length === 0) return null; | ||
|
|
||
| // Helper to check if a team name matches | ||
| const isTeamMatch = (name) => { | ||
| if (!name) return false; | ||
| const lowerName = name.toLowerCase(); | ||
| return lowerName === teamPagename.toLowerCase() || lowerName === teamName.toLowerCase(); | ||
| }; | ||
|
|
||
| // Sort by date descending and take up to 5 | ||
| const sortedMatches = [...matches] | ||
| .filter(m => { | ||
| if (!m.match2opponents || m.match2opponents.length < 2) return false; | ||
| return m.finished === '1' || m.finished === 1 || m.winner; | ||
| }) | ||
| .sort((a, b) => new Date(b.date) - new Date(a.date)) | ||
| .slice(0, 5); | ||
|
|
||
| if (sortedMatches.length === 0) return null; | ||
|
|
||
| const results = sortedMatches.map(match => { | ||
| const team1 = match.match2opponents[0]; | ||
| const team2 = match.match2opponents[1]; | ||
|
|
||
| const isTeam1 = isTeamMatch(team1?.name); | ||
|
|
||
| const opponentName = isTeam1 ? (team2?.name || 'Unknown') : (team1?.name || 'Unknown'); | ||
| const teamScore = isTeam1 ? (team1?.score ?? '?') : (team2?.score ?? '?'); | ||
| const opponentScore = isTeam1 ? (team2?.score ?? '?') : (team1?.score ?? '?'); | ||
|
|
||
| // Determine result emoji | ||
| let resultEmoji = '🔸'; // Draw/unknown | ||
| if (match.winner !== undefined && match.winner !== null) { | ||
| if ((isTeam1 && (match.winner === 1 || match.winner === '1')) || | ||
| (!isTeam1 && (match.winner === 2 || match.winner === '2'))) { | ||
| resultEmoji = '✅'; | ||
| } else { | ||
| resultEmoji = '❌'; | ||
| } | ||
| } else { | ||
| // Fallback to score comparison | ||
| const score1 = parseInt(teamScore) || 0; | ||
| const score2 = parseInt(opponentScore) || 0; | ||
| if (score1 > score2) resultEmoji = '✅'; | ||
| else if (score2 > score1) resultEmoji = '❌'; | ||
| } | ||
|
|
||
| return `${resultEmoji} vs ${opponentName}: **${teamScore}** - ${opponentScore}`; | ||
| }); | ||
|
|
||
| return results.join('\n'); | ||
| } |
There was a problem hiding this comment.
This entire file appears unrelated to the stated PR purpose of adding tier icons to the skin command. This introduces a new team_standings command for Liquipedia data, which should be in a separate PR for better code review and version control practices.
index.js
Outdated
| ----------------------------------------------------- */ | ||
|
|
||
| client.once('ready', () => { | ||
| client.once('clientReady', () => { |
There was a problem hiding this comment.
This change from 'ready' to 'clientReady' appears unrelated to the stated PR purpose of adding tier icons to the skin command. While 'clientReady' is the correct event name in Discord.js v14, this change should be in a separate PR for better code organization and review.
ce2357d to
57dd180
Compare
|
Åbenbart er det ikke nok at hoste Emojis på https://discord.com/developers/applications/1443951006848122992/emojis, de emojis skal også være tilgængelige på Discord Serveren som botten er på (Heraf Pley serveren). Så det skulle gerne virke med tier emojis på skins for nu, men vi skal have agent emojis på serveren først før vi bør arbejde videre med dem. |
57dd180 to
c25d839
Compare
Added emojis for the tiers in the skin command