diff --git a/.env.example b/.env.example index 69494aa..bc98e6e 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,10 @@ CLIENT_ID=your_discord_client_id_here LEETIFY_API_KEY=your_leetify_api_key_here LEETIFY_API_BASE_URL=https://api-public.cs-prod.leetify.com +# ChatGPT Configuration (Optional) +CHATGPT_ENABLED=false # Set to 'true' to enable ChatGPT-generated roasts +CHATGPT_API_KEY=your_openai_api_key # OpenAI API key (can also use OPENAI_API_KEY) + # Bot Settings PREFIX=! diff --git a/README.md b/README.md index 6dee46f..a166312 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,28 @@ LEETIFY_API_KEY=your_leetify_api_key LEETIFY_API_BASE_URL=https://api-public.cs-prod.leetify.com CHECK_INTERVAL_MINUTES=60 USER_COOLDOWN_HOURS=3 + +# Optional: ChatGPT Integration +CHATGPT_ENABLED=false +CHATGPT_API_KEY=your_openai_api_key ``` +### ChatGPT Roasts (Optional) + +Enable AI-generated roasts using OpenAI's ChatGPT: + +1. Get an API key from [OpenAI](https://platform.openai.com/api-keys) +2. Set `CHATGPT_ENABLED=true` in your `.env` file +3. Add your `CHATGPT_API_KEY` + +**Features:** +- Personalized roasts based on player stats +- Automatic caching (same roast for same match count) +- Fallback to traditional roasts if API fails +- Uses GPT-4o-mini for cost efficiency + +**Note:** ChatGPT API usage incurs costs. Traditional roasts are free and always available as fallback. + ### Run ```bash diff --git a/config.js b/config.js index c09f3e8..f9d5a05 100644 --- a/config.js +++ b/config.js @@ -7,6 +7,10 @@ module.exports = { leetifyApiKey: process.env.LEETIFY_API_KEY, leetifyApiBaseUrl: process.env.LEETIFY_API_BASE_URL || 'https://api-public.cs-prod.leetify.com', + // ChatGPT Configuration + chatGPTEnabled: process.env.CHATGPT_ENABLED === 'true', + chatGPTApiKey: process.env.CHATGPT_API_KEY || process.env.OPENAI_API_KEY, + // Bot Settings maxGamesToAnalyze: 10, roastIntensity: 'medium', // low, medium, high diff --git a/services/chatGPTRoastGenerator.js b/services/chatGPTRoastGenerator.js new file mode 100644 index 0000000..901cb35 --- /dev/null +++ b/services/chatGPTRoastGenerator.js @@ -0,0 +1,309 @@ +const fs = require('fs'); +const path = require('path'); + +/** + * ChatGPT-based roast generator with caching + * Generates personalized roasts based on CS2 stats using OpenAI's API + */ +class ChatGPTRoastGenerator { + constructor() { + this.cacheFilePath = path.join(__dirname, '../data/chatgptRoastCache.json'); + this.cache = this.loadCache(); + this.enabled = false; + this.apiKey = null; + this.model = 'gpt-4o-mini'; // Cost-effective model for roasts + } + + /** + * Initialize the ChatGPT roast generator + * @param {string} apiKey - OpenAI API key + * @param {boolean} enabled - Whether ChatGPT roasts are enabled + */ + initialize(apiKey, enabled = false) { + this.apiKey = apiKey; + this.enabled = enabled && !!apiKey; + + if (this.enabled) { + console.log('[CHATGPT] ChatGPT roast generator enabled'); + } else { + console.log('[CHATGPT] ChatGPT roast generator disabled - using traditional roasts'); + } + } + + /** + * Check if ChatGPT roasts are enabled + * @returns {boolean} + */ + isEnabled() { + return this.enabled && !!this.apiKey; + } + + /** + * Load roast cache from disk + * @returns {Object} + */ + loadCache() { + try { + if (fs.existsSync(this.cacheFilePath)) { + const data = fs.readFileSync(this.cacheFilePath, 'utf8'); + return JSON.parse(data); + } + } catch (error) { + console.error('[CHATGPT] Error loading cache:', error); + } + return {}; + } + + /** + * Save roast cache to disk + */ + saveCache() { + try { + const dir = path.dirname(this.cacheFilePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(this.cacheFilePath, JSON.stringify(this.cache, null, 2)); + } catch (error) { + console.error('[CHATGPT] Error saving cache:', error); + } + } + + /** + * Generate a cache key based on user stats + * @param {string} userId - Discord user ID + * @param {Object} stats - User statistics + * @param {number} matchCount - Total match count + * @returns {string} + */ + generateCacheKey(userId, stats, matchCount) { + // Cache key includes userId and matchCount to ensure unique roasts per match update + return `${userId}_${matchCount}_${stats.winRate.toFixed(1)}_${stats.aimRating.toFixed(1)}`; + } + + /** + * Get cached roast if available + * @param {string} cacheKey - Cache key + * @returns {string|null} + */ + getCachedRoast(cacheKey) { + if (this.cache[cacheKey]) { + const cached = this.cache[cacheKey]; + console.log(`[CHATGPT] Using cached roast for key: ${cacheKey.substring(0, 30)}...`); + return cached.roast; + } + return null; + } + + /** + * Cache a roast + * @param {string} cacheKey - Cache key + * @param {string} roast - Generated roast + */ + cacheRoast(cacheKey, roast) { + this.cache[cacheKey] = { + roast, + generatedAt: new Date().toISOString(), + }; + this.saveCache(); + } + + /** + * Generate a ChatGPT roast based on stats + * @param {Object} stats - User statistics + * @param {Object} previousStats - Previous statistics (optional) + * @param {string} playerName - Player's name + * @returns {Promise} + */ + async generateRoast(stats, previousStats, playerName = 'this player') { + if (!this.isEnabled()) { + throw new Error('ChatGPT roast generator is not enabled'); + } + + try { + const prompt = this.buildPrompt(stats, previousStats, playerName); + + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + model: this.model, + messages: [ + { + role: 'system', + content: 'You are the most savage, brutally honest CS2 trash-talker. Your roasts are RUTHLESS, cutting, and unforgiving. Hold NOTHING back. Destroy their ego with cold hard facts about their terrible stats. Be creative, aggressive, and absolutely merciless. Make them question why they even installed the game. IMPORTANT: Do NOT include the player\'s name in your roast - they will be tagged separately. Keep roasts under 180 characters. ALWAYS end your roast with the exact stat you\'re referencing in parentheses (e.g., "your aim is trash (Aim: 45.2)" or "you can\'t position worth a damn (Positioning: 38.1)"). Make every word COUNT. Use gaming slang to twist the knife deeper.', + }, + { + role: 'user', + content: prompt, + }, + ], + max_tokens: 150, + temperature: 0.9, // High creativity for varied roasts + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(`OpenAI API error: ${errorData.error?.message || response.statusText}`); + } + + const data = await response.json(); + const roast = data.choices[0]?.message?.content?.trim(); + + if (!roast) { + throw new Error('No roast generated from ChatGPT'); + } + + console.log('[CHATGPT] Generated new roast via ChatGPT API'); + return roast; + } catch (error) { + console.error('[CHATGPT] Error generating roast:', error); + throw error; + } + } + + /** + * Build prompt for ChatGPT based on stats + * @param {Object} stats - User statistics + * @param {Object} previousStats - Previous statistics (optional) + * @param {string} playerName - Player's name + * @returns {string} + */ + buildPrompt(stats, previousStats, playerName) { + let prompt = `STATS INTERPRETATION GUIDE: +- Ratings (Aim, Positioning, Utility): 0-100 scale where 50 is average. Below 45 is BAD, below 40 is TERRIBLE. +- Deviation stats (Clutch, Opening, CT/T Leetify): Shows deviation from average (0). Negative = worse than average. +- Percentages: Higher is usually better except reaction time (lower is better) and team-damaging stats. +- Win Rate: Below 50% means losing more than winning. + +Roast ${playerName} based on these CS2 stats: + +=== CORE PERFORMANCE === +Win Rate: ${stats.winRate.toFixed(1)}% ${stats.winRate < 50 ? '(LOSING PLAYER)' : ''} +Games Analyzed: ${stats.gamesAnalyzed} + +=== RATINGS (0-100, 50=average) === +Aim: ${stats.aimRating.toFixed(1)} ${stats.aimRating < 45 ? '(BELOW AVERAGE)' : stats.aimRating < 40 ? '(TERRIBLE)' : ''} +Positioning: ${stats.positioningRating.toFixed(1)} ${stats.positioningRating < 45 ? '(BELOW AVERAGE)' : stats.positioningRating < 40 ? '(TERRIBLE)' : ''} +Utility Usage: ${stats.utilityRating.toFixed(1)} ${stats.utilityRating < 45 ? '(BELOW AVERAGE)' : stats.utilityRating < 40 ? '(TERRIBLE)' : ''} + +=== ACCURACY & MECHANICS === +Headshot Rate: ${stats.headshotRate.toFixed(1)}% ${stats.headshotRate < 40 ? '(BODY SHOT BOT)' : ''} +Accuracy (Enemy Spotted): ${stats.accuracy.toFixed(1)}% +Spray Accuracy: ${stats.sprayAccuracy.toFixed(1)}% +Counter-Strafing Good Shots: ${stats.counterStrafing.toFixed(1)}% +Preaim: ${stats.preaim.toFixed(1)}° ${stats.preaim > 20 ? '(HORRIBLE CROSSHAIR PLACEMENT)' : ''} +Reaction Time: ${stats.reactionTime.toFixed(0)}ms ${stats.reactionTime > 250 ? '(SLOW AF)' : stats.reactionTime > 200 ? '(SLUGGISH)' : ''} + +=== PERFORMANCE DEVIATIONS (negative = worse than average) === +Clutch: ${stats.clutchDeviation >= 0 ? '+' : ''}${stats.clutchDeviation.toFixed(2)} ${stats.clutchDeviation < -5 ? '(CLUTCH CHOKER)' : ''} +Opening Duels: ${stats.openingDeviation >= 0 ? '+' : ''}${stats.openingDeviation.toFixed(2)} ${stats.openingDeviation < -5 ? '(LOSES FIGHTS)' : ''} +CT Side Leetify: ${stats.ctLeetifyDeviation >= 0 ? '+' : ''}${stats.ctLeetifyDeviation.toFixed(2)} +T Side Leetify: ${stats.tLeetifyDeviation >= 0 ? '+' : ''}${stats.tLeetifyDeviation.toFixed(2)} + +=== TEAM PLAY === +Trade Kills Success: ${stats.tradeKillsSuccessPercentage.toFixed(1)}% ${stats.tradeKillsSuccessPercentage < 50 ? '(BAD TEAMMATE)' : ''} +Traded Deaths Success: ${stats.tradedDeathsSuccessPercentage.toFixed(1)}% +Flashbang Enemies Hit Per Flash: ${stats.flashbangHitFoePerFlashbang.toFixed(2)} +Flashbang Teammates Hit Per Flash: ${stats.flashbangHitFriendPerFlashbang.toFixed(2)} ${stats.flashbangHitFriendPerFlashbang > 0.3 ? '(TEAM FLASHER)' : ''} +Utility Left on Death: ${stats.utilityOnDeathAvg.toFixed(1)} ${stats.utilityOnDeathAvg > 200 ? '(NADE HOARDER)' : ''} + +=== OPENING DUELS === +CT Opening Success: ${stats.ctOpeningDuelSuccessPercentage.toFixed(1)}% +T Opening Success: ${stats.tOpeningDuelSuccessPercentage.toFixed(1)}%${previousStats ? ` + +=== CHANGES FROM LAST MATCH ===` : ''}`; + + // Comparison with previous stats + if (previousStats) { + const changes = [ + { name: 'Win Rate', current: stats.winRate, prev: previousStats.winRate, suffix: '%' }, + { name: 'Aim', current: stats.aimRating, prev: previousStats.aimRating }, + { name: 'Headshot Rate', current: stats.headshotRate, prev: previousStats.headshotRate, suffix: '%' }, + { name: 'Positioning', current: stats.positioningRating, prev: previousStats.positioningRating }, + { name: 'Utility', current: stats.utilityRating, prev: previousStats.utilityRating }, + { name: 'Clutch', current: stats.clutchDeviation, prev: previousStats.clutchDeviation }, + { name: 'Opening', current: stats.openingDeviation, prev: previousStats.openingDeviation }, + ]; + + const significantChanges = changes.filter(c => Math.abs(c.current - c.prev) > 1); + if (significantChanges.length > 0) { + significantChanges.forEach(change => { + const diff = change.current - change.prev; + const direction = diff >= 0 ? 'UP' : 'DOWN'; + const emoji = diff < 0 ? '📉' : '📈'; + prompt += `\n${change.name}: ${diff >= 0 ? '+' : ''}${diff.toFixed(1)}${change.suffix || ''} ${emoji} (${direction})`; + }); + } else { + prompt += '\nNo significant changes (still trash)'; + } + } + + prompt += '\n\nGenerate ONE brutal, savage roast focusing on their WORST stats. Reference the specific stat at the end in parentheses.'; + + return prompt; + } + + /** + * Get or generate a roast with caching + * @param {string} userId - Discord user ID + * @param {Object} stats - User statistics + * @param {Object} previousStats - Previous statistics (optional) + * @param {number} matchCount - Total match count + * @param {string} playerName - Player's name + * @returns {Promise} + */ + async getOrGenerateRoast(userId, stats, previousStats, matchCount, playerName = 'this player') { + if (!this.isEnabled()) { + throw new Error('ChatGPT roast generator is not enabled'); + } + + const cacheKey = this.generateCacheKey(userId, stats, matchCount); + + // Check cache first + const cachedRoast = this.getCachedRoast(cacheKey); + if (cachedRoast) { + return cachedRoast; + } + + // Generate new roast + const roast = await this.generateRoast(stats, previousStats, playerName); + + // Cache it + this.cacheRoast(cacheKey, roast); + + return roast; + } + + /** + * Clear old cache entries (optional cleanup) + * @param {number} daysToKeep - Number of days to keep cache entries + */ + clearOldCache(daysToKeep = 30) { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysToKeep); + + let clearedCount = 0; + for (const [key, value] of Object.entries(this.cache)) { + const generatedDate = new Date(value.generatedAt); + if (generatedDate < cutoffDate) { + delete this.cache[key]; + clearedCount++; + } + } + + if (clearedCount > 0) { + this.saveCache(); + console.log(`[CHATGPT] Cleared ${clearedCount} old cache entries`); + } + } +} + +// Singleton instance +const chatGPTRoastGenerator = new ChatGPTRoastGenerator(); + +module.exports = chatGPTRoastGenerator; diff --git a/services/matchTracker.js b/services/matchTracker.js index 54096e4..0c93aaa 100644 --- a/services/matchTracker.js +++ b/services/matchTracker.js @@ -2,6 +2,8 @@ const fs = require('fs'); const path = require('path'); const leetifyApi = require('./leetifyApi'); const cs2RoastGenerator = require('../utils/cs2RoastGenerator'); +const chatGPTRoastGenerator = require('./chatGPTRoastGenerator'); +const config = require('../config'); const { loadUserLinks } = require('../utils/userLinksManager'); const { getGuildConfig } = require('../utils/guildConfigManager'); @@ -79,6 +81,9 @@ class MatchTracker { this.client = discordClient; console.log('Match tracker initialized'); + // Initialize ChatGPT roast generator + chatGPTRoastGenerator.initialize(config.chatGPTApiKey, config.chatGPTEnabled); + // Start tracking when bot is ready if (this.client.isReady()) { this.startTracking(); @@ -267,14 +272,36 @@ class MatchTracker { } const playerName = profileData.name || 'Unknown Player'; + const currentMatchCount = profileData.total_matches || 0; // Generate roast once (cached for all guilds) - ensures same roast for same user const cacheKey = `${discordUserId}-${Date.now()}`; let selectedRoast; if (!this.roastCache[cacheKey]) { - const roasts = cs2RoastGenerator.generateRoastsWithComparison(currentStats, previousStats); - selectedRoast = roasts[Math.floor(Math.random() * roasts.length)]; + // Use ChatGPT if enabled, otherwise use traditional roasts + if (chatGPTRoastGenerator.isEnabled()) { + try { + selectedRoast = await chatGPTRoastGenerator.getOrGenerateRoast( + discordUserId, + currentStats, + previousStats, + currentMatchCount, + playerName, + ); + console.log(`[CHATGPT] Generated roast for ${playerName}`); + } catch (error) { + console.error('[CHATGPT] Failed to generate roast, falling back to traditional:', error); + // Fallback to traditional roasts + const roasts = cs2RoastGenerator.generateRoastsWithComparison(currentStats, previousStats); + selectedRoast = roasts[Math.floor(Math.random() * roasts.length)]; + } + } else { + // Traditional roast generation + const roasts = cs2RoastGenerator.generateRoastsWithComparison(currentStats, previousStats); + selectedRoast = roasts[Math.floor(Math.random() * roasts.length)]; + } + this.roastCache[cacheKey] = selectedRoast; // Clear old cache entries (keep only last 100) diff --git a/slashCommands/roast.js b/slashCommands/roast.js index bfe693d..fb714e3 100644 --- a/slashCommands/roast.js +++ b/slashCommands/roast.js @@ -1,6 +1,7 @@ const { SlashCommandBuilder, EmbedBuilder, MessageFlags } = require('discord.js'); const leetifyApi = require('../services/leetifyApi'); const cs2RoastGenerator = require('../utils/cs2RoastGenerator'); +const chatGPTRoastGenerator = require('../services/chatGPTRoastGenerator'); const { getUserSteam64Id } = require('../utils/userLinksManager'); module.exports = { @@ -52,10 +53,32 @@ module.exports = { const profileData = await leetifyApi.getProfile(steam64Id); const stats = cs2RoastGenerator.calculateStatsFromProfile(profileData); const playerName = profileData.name || targetUser.username; + const currentMatchCount = profileData.total_matches || 0; - // Generate roast (no previous stats for comparison) - const roasts = cs2RoastGenerator.generateRoastsWithComparison(stats, null); - const selectedRoast = roasts[Math.floor(Math.random() * roasts.length)]; + // Generate roast using ChatGPT if enabled, otherwise use traditional + let selectedRoast; + + if (chatGPTRoastGenerator.isEnabled()) { + try { + selectedRoast = await chatGPTRoastGenerator.getOrGenerateRoast( + userId, + stats, + null, // No previous stats for instant roast + currentMatchCount, + playerName, + ); + console.log('[ROAST CMD] Using ChatGPT roast'); + } catch (error) { + console.error('[ROAST CMD] ChatGPT failed, falling back to traditional:', error); + // Fallback to traditional roasts + const roasts = cs2RoastGenerator.generateRoastsWithComparison(stats, null); + selectedRoast = roasts[Math.floor(Math.random() * roasts.length)]; + } + } else { + // Traditional roast generation + const roasts = cs2RoastGenerator.generateRoastsWithComparison(stats, null); + selectedRoast = roasts[Math.floor(Math.random() * roasts.length)]; + } // Build roast message let roastMessage = `${targetUser}, ${selectedRoast}`;