From ee6dbc1df2a143f895ff7d4592bc900ccf919a03 Mon Sep 17 00:00:00 2001 From: Kira Date: Mon, 12 Jan 2026 16:46:00 -0500 Subject: [PATCH] inactive command to show people not participating in OC --- .gitignore | 4 +- commands/stupid/howiskzn.js | 35 ++++++----- commands/utility/inactive.js | 112 +++++++++++++++++++++++++++++++++++ commands/utility/scanOC.js | 85 ++++++++++++++++++++++++++ tasks/trackOC.js | 61 +++++++++++++++++++ torn.js | 40 ++++++++++++- utils/ocLogic.js | 112 +++++++++++++++++++++++++++++++++++ 7 files changed, 428 insertions(+), 21 deletions(-) create mode 100644 commands/utility/inactive.js create mode 100644 commands/utility/scanOC.js create mode 100644 tasks/trackOC.js create mode 100644 utils/ocLogic.js diff --git a/.gitignore b/.gitignore index 8a10abc..7d62e3d 100644 --- a/.gitignore +++ b/.gitignore @@ -140,6 +140,6 @@ dist config.json state.json -cache.json docker-compose.yml -public/ \ No newline at end of file +public/ +data/ \ No newline at end of file diff --git a/commands/stupid/howiskzn.js b/commands/stupid/howiskzn.js index 34ddb6d..df17ebc 100644 --- a/commands/stupid/howiskzn.js +++ b/commands/stupid/howiskzn.js @@ -7,14 +7,17 @@ module.exports = { .setDescription('How is KZNKing doing'), async execute(interaction) { const kznID = 3392710 - const KZNKing = await torn.user.profile(kznID); - let message = `${KZNKing.name} has ${KZNKing.friends} friends and ${KZNKing.enemies} enemies. `; - (KZNKing.married.duration) ? message += `He has been married to [${KZNKing.married.spouse_name}](https://www.torn.com/profiles.php?XID=${KZNKing.married.spouse_id}) for ${KZNKing.married.duration} days. ` : message += `He is not married. `; - (KZNKing.property === "Private Island") ? message += `He has a Private Island. ` : message += `He does not have a Private Island. `; - (KZNKing.job.position === "Director") ? message += `He is director of ${KZNKing.job.company_name}. ` : message += `He is not director of his company. `; - (KZNKing.faction.position === "Leader") ? message += `He is leader of ${KZNKing.faction.faction_name}. ` : message += `He is not leader of his faction. `; + const data = await torn.user.get(kznID, ['profile', 'job', 'faction']); - const company = (await torn.company(KZNKing.job.company_id)); + const KZNKing = { ...data.profile, job: data.job, faction: data.faction }; + + let message = `${KZNKing.name} has ${KZNKing.friends} friends and ${KZNKing.enemies} enemies. `; + (KZNKing.spouse && KZNKing.spouse.days_married) ? message += `He has been married to [${KZNKing.spouse.name}](https://www.torn.com/profiles.php?XID=${KZNKing.spouse.id}) for ${KZNKing.spouse.days_married} days. ` : message += `He is not married. `; + (KZNKing.property === "Private Island") ? message += `He has a Private Island. ` : message += `He does not have a Private Island. `; + (KZNKing.job.position === "Director") ? message += `He is director of ${KZNKing.job.name}. ` : message += `He is not director of his company. `; + (KZNKing.faction.position === "Leader") ? message += `He is leader of ${KZNKing.faction.name}. ` : message += `He is not leader of his faction. `; + + const company = (await torn.company(KZNKing.job.id)); const embeds = []; if (KZNKing.job.position === "Director") { const jobEmbed = new EmbedBuilder() @@ -60,35 +63,35 @@ module.exports = { embeds.push(jobEmbed); } - const faction = await torn.faction.basic(KZNKing.faction.faction_id) + const factionBasic = await torn.faction.basic(KZNKing.faction.id) if (KZNKing.faction.position === "Leader") { const facEmbed = new EmbedBuilder() - .setTitle(faction.name) - .setURL(`https://www.torn.com/factions.php?step=profile&ID=${faction.id}`) + .setTitle(factionBasic.name) + .setURL(`https://www.torn.com/factions.php?step=profile&ID=${factionBasic.id}`) .addFields( { name: "Members", - value: `${faction.members}/${faction.capacity}`, + value: `${factionBasic.members}/${factionBasic.capacity}`, inline: true }, { name: "Rank", - value: `${faction.rank.name} ${faction.rank.division}`, + value: `${factionBasic.rank.name} ${factionBasic.rank.division}`, inline: true }, { name: "Respect", - value: `${faction.respect.toLocaleString()}`, + value: `${factionBasic.respect.toLocaleString()}`, inline: true }, { name: "Age", - value: `${faction.days_old}`, + value: `${factionBasic.days_old}`, inline: true }, { name: "Wars Won", - value: `${faction.rank.wins}`, + value: `${factionBasic.rank.wins}`, inline: true }, ); @@ -108,7 +111,7 @@ module.exports = { let factionFemales = 0; let factionTotal = 0; - const factionMembers = await torn.faction.members(KZNKing.faction.faction_id); + const factionMembers = await torn.faction.members(KZNKing.faction.id); const factionFemalePromises = factionMembers.map((user) => { return torn.user.basic(user.id).then(data => { factionTotal++; diff --git a/commands/utility/inactive.js b/commands/utility/inactive.js new file mode 100644 index 0000000..da1147d --- /dev/null +++ b/commands/utility/inactive.js @@ -0,0 +1,112 @@ +const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); +const fs = require('fs'); +const path = require('path'); +const torn = require('../../torn.js'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('inactive') + .setDescription('Shows users who haven\'t participated in an OC recently.') + .addIntegerOption(option => + option.setName('days') + .setDescription('Number of days of inactivity (default 3)') + .setMinValue(1) + ), + async execute(interaction) { + await interaction.deferReply(); + + const days = interaction.options.getInteger('days') || 3; + const cutoffTime = Date.now() - (days * 24 * 60 * 60 * 1000); + const statsPath = path.join(__dirname, '../../data/ocStats.json'); + + // Load tracked stats + let stats = {}; + if (fs.existsSync(statsPath)) { + try { + stats = JSON.parse(fs.readFileSync(statsPath, 'utf8')); + } catch (e) { + console.error("inactive: Failed to load ocStats.json", e); + } + } + + let members = []; + try { + // Fetch own faction members + members = await torn.faction.members(); + } catch (e) { + console.error("inactive: Failed to fetch members", e); + return interaction.editReply('Failed to fetch faction members from API.'); + } + + const inactiveUsers = []; + + // Check each member + for (const member of members) { + const userId = member.id; + const userName = member.name; + const userStat = stats[userId]; + + if (!userStat) { + // Never seen in tracking + inactiveUsers.push({ + id: userId, + name: userName, + lastSeen: null, + daysInactive: -1 + }); + } else { + if (userStat.lastSeen < cutoffTime) { + inactiveUsers.push({ + id: userId, + name: userName, + lastSeen: new Date(userStat.lastSeen), + daysInactive: Math.floor((Date.now() - userStat.lastSeen) / (24 * 60 * 60 * 1000)) + }); + } + } + } + + // Sort: Never seen first, then by longest inactivity + inactiveUsers.sort((a, b) => { + if (a.lastSeen === null && b.lastSeen === null) return 0; + if (a.lastSeen === null) return -1; // a comes first + if (b.lastSeen === null) return 1; // b comes first + return a.lastSeen - b.lastSeen; // Older timestamp (smaller) comes first + }); + + const embed = new EmbedBuilder() + .setTitle(`Inactive OC Members (> ${days} days)`) + .setColor(0xFF0000) + .setTimestamp(); + + if (inactiveUsers.length === 0) { + embed.setDescription(`Everyone has participated in an OC in the last ${days} days!`); + embed.setColor(0x00FF00); + } else { + const limit = 25; + const shownUsers = inactiveUsers.slice(0, limit); + + shownUsers.forEach(user => { + let value = ""; + if (user.lastSeen === null) { + value = "Never seen in OCs (since tracking started)"; + } else { + const ts = Math.floor(user.lastSeen.getTime() / 1000); + value = `Last crime: \n()`; + } + + embed.addFields({ + name: `${user.name} [${user.id}]`, + value: value, + inline: true + }); + }); + + if (inactiveUsers.length > limit) { + embed.setFooter({ text: `...and ${inactiveUsers.length - limit} more.` }); + } + } + + await interaction.editReply({ embeds: [embed] }); + }, +}; diff --git a/commands/utility/scanOC.js b/commands/utility/scanOC.js new file mode 100644 index 0000000..b6c997f --- /dev/null +++ b/commands/utility/scanOC.js @@ -0,0 +1,85 @@ +const { SlashCommandBuilder } = require('discord.js'); +const fs = require('fs'); +const path = require('path'); +const { getMemberMap, processCrimes } = require('../../utils/ocLogic'); +const torn = require('../../torn.js'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('scanoc') + .setDescription('Scans historical OCs to populate participation stats.') + .addIntegerOption(option => + option.setName('days') + .setDescription('How many days back to scan (default 30)') + .setMinValue(1) + .setMaxValue(365) + ), + async execute(interaction) { + await interaction.deferReply(); + + const days = interaction.options.getInteger('days') || 30; + const now = Date.now(); + const fromTimestamp = Math.floor((now - (days * 24 * 60 * 60 * 1000)) / 1000); + const statsPath = path.join(__dirname, '../../data/ocStats.json'); + + // Load existing stats + let stats = {}; + if (fs.existsSync(statsPath)) { + try { + stats = JSON.parse(fs.readFileSync(statsPath, 'utf8')); + } catch (e) { + console.error("scanOC: Failed to load ocStats.json", e); + } + } + + // Fetch faction members + const memberMap = await getMemberMap(torn); + + await interaction.editReply(`Scanning OCs from the last ${days} days...`); + + let crimesList = []; + const categories = ['recruiting', 'planned', 'active', 'successful', 'failed']; + + for (const cat of categories) { + try { + // Fetch with a higher limit since we are scanning back further + const crimes = await torn.faction.crimes({ + from: fromTimestamp, + sort: 'ASC', + category: cat, + limit: 300 // Reasonable batch size? + }); + + if (crimes && Array.isArray(crimes)) { + crimesList = crimesList.concat(crimes); + } + } catch (e) { + console.error(`scanOC: Failed to fetch crimes for category '${cat}'`, e); + } + } + + if (crimesList.length === 0) { + return interaction.editReply(`Scan complete. No OCs found in the last ${days} days.`); + } + + // Process with utility + const updates = await processCrimes(crimesList, stats, memberMap, torn); + + // Save + if (updates > 0) { + try { + const dir = path.dirname(statsPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(statsPath, JSON.stringify(stats, null, 4)); + await interaction.editReply(`Scan complete. Processed ${crimesList.length} crimes. Updated stats for ${updates} users.`); + } catch (e) { + console.error("scanOC: Failed to save stats", e); + await interaction.editReply(`Scan complete, but failed to save stats: ${e.message}`); + } + } else { + await interaction.editReply(`Scan complete. Processed ${crimesList.length} crimes. No new updates needed.`); + } + }, +}; diff --git a/tasks/trackOC.js b/tasks/trackOC.js new file mode 100644 index 0000000..fbd17f0 --- /dev/null +++ b/tasks/trackOC.js @@ -0,0 +1,61 @@ +const fs = require('fs'); +const path = require('path'); +const { getMemberMap, processCrimes } = require('../utils/ocLogic'); + +module.exports = async (client, torn, config) => { + const statsPath = path.join(__dirname, '../data/ocStats.json'); + + // Load existing stats + let stats = {}; + try { + if (fs.existsSync(statsPath)) { + stats = JSON.parse(fs.readFileSync(statsPath, 'utf8')); + } + } catch (e) { + console.error("trackOC: Failed to load ocStats.json", e); + } + + // Fetch faction members + const memberMap = await getMemberMap(torn); + + // Fetch recent crimes (last 48 hours to be safe and catch up) + const now = Date.now(); + const twoDaysAgo = Math.floor((now - (48 * 60 * 60 * 1000)) / 1000); + + let crimesList = []; + const categories = ['recruiting', 'planned', 'active', 'successful', 'failed']; + + for (const cat of categories) { + try { + const crimes = await torn.faction.crimes({ from: twoDaysAgo, sort: 'ASC', category: cat }); + if (crimes && Array.isArray(crimes)) { + crimesList = crimesList.concat(crimes); + } + } catch (e) { + console.error(`trackOC: Failed to fetch crimes for category '${cat}'`, e); + } + } + + if (!crimesList || crimesList.length === 0) { + console.debug("trackOC: No crimes found in the last 48 hours."); + return; + } + + // Process crimes using utility + const updates = await processCrimes(crimesList, stats, memberMap, torn); + + if (updates > 0) { + try { + const dir = path.dirname(statsPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(statsPath, JSON.stringify(stats, null, 4)); + console.log(`trackOC: Updated participation for ${updates} users.`); + } catch (e) { + console.error("trackOC: Failed to save stats", e); + } + } else { + console.debug("trackOC: No new updates."); + } +}; \ No newline at end of file diff --git a/torn.js b/torn.js index 3e5655e..f0cc02b 100644 --- a/torn.js +++ b/torn.js @@ -10,7 +10,7 @@ try { } try { - cache = require('./cache.json'); + cache = require('./data/cache.json'); } catch (e) { cache = { users: {}, @@ -19,7 +19,11 @@ try { items: {} }; try { - fs.writeFileSync('./cache.json', JSON.stringify(cache)); + const dir = './data'; + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync('./data/cache.json', JSON.stringify(cache)); } catch (writeErr) { console.error("Failed to write initial cache.json", writeErr); } @@ -38,7 +42,11 @@ const TTL = { // Helper to save cache function saveCache() { try { - fs.writeFileSync('./cache.json', JSON.stringify(cache)); + const dir = './data'; + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync('./data/cache.json', JSON.stringify(cache)); } catch (e) { console.error("Failed to save cache:", e); } @@ -151,6 +159,32 @@ const api = { if (statName) { url += `&stat=${statName}`; } return fetchApi(url); }, + async job(user, force = false) { + const endpoint = user ? `https://api.torn.com/v2/user/${user}/job` : `https://api.torn.com/v2/user/job`; + return getCached('users_job', user || 'self', async () => { + const data = await fetchApi(endpoint); + return data.job; + }, TTL.USER, force); + }, + async faction(user, force = false) { + const endpoint = user ? `https://api.torn.com/v2/user/${user}/faction` : `https://api.torn.com/v2/user/faction`; + return getCached('users_faction', user || 'self', async () => { + const data = await fetchApi(endpoint); + return data.faction; + }, TTL.USER, force); + }, + async get(user, selections = [], force = false) { + const selStr = selections.join(','); + const endpoint = user ? `https://api.torn.com/v2/user/${user}?selections=${selStr}` : `https://api.torn.com/v2/user?selections=${selStr}`; + // Cache usage for composite calls is tricky. For now, let's skip complex caching or cache by selection string key. + // A simple key like "users_profile_job_faction_ID" works. + const cacheKey = selections.sort().join('_'); + + return getCached(`users_${cacheKey}`, user || 'self', async () => { + const data = await fetchApi(endpoint); + return data; + }, TTL.USER, force); + }, }, faction: { diff --git a/utils/ocLogic.js b/utils/ocLogic.js new file mode 100644 index 0000000..7fa6b6b --- /dev/null +++ b/utils/ocLogic.js @@ -0,0 +1,112 @@ +const fs = require('fs'); +const path = require('path'); + +/** + * Fetches faction members and returns a map of ID -> Name. + * @param {Object} torn The torn wrapper instance. + * @returns {Promise} Map of member ID to Name. + */ +async function getMemberMap(torn) { + let memberMap = {}; + try { + const members = await torn.faction.members(); + members.forEach(m => memberMap[m.id] = m.name); + } catch (e) { + console.error("ocLogic: Failed to fetch faction members for name resolution", e); + } + return memberMap; +} + +/** + * Calculates the best timestamp for a crime participation. + * @param {Object} slot The user slot object. + * @param {Object} crime The crime object. + * @returns {number|null} Timestamp in milliseconds, or null if invalid. + */ +function calculateCrimeTimestamp(slot, crime) { + // Priority: Max of (User join time, Crime related times) + const timestamps = [ + slot.user.joined_at, + crime.initiated_at, + crime.time_started, + crime.time_completed, + crime.executed_at, + crime.time_ready, + crime.created_at + ].filter(t => t && t > 0); + + if (timestamps.length === 0) { + return null; + } + + return Math.max(...timestamps) * 1000; +} + +/** + * Processes a list of crimes and updates the stats object. + * @param {Array} crimesList List of crime objects. + * @param {Object} stats The current stats object. + * @param {Object} memberMap Map of user ID to name. + * @returns {number} Number of updates made. + */ +async function processCrimes(crimesList, stats, memberMap, torn) { + let updatedUsers = new Set(); + // We iterate sequentially to allow async fetching if needed without blasting API + for (const crime of crimesList) { + if (crime.slots && Array.isArray(crime.slots)) { + for (const slot of crime.slots) { + if (!slot.user) continue; // Skip if user data is missing + const userId = slot.user.id; + + // Try to resolve name + let userName = slot.user.name; + if (!userName) userName = memberMap[userId]; + + if ((!userName || userName === 'Unknown') && userId && torn) { + try { + const profile = await torn.user.profile(userId); + if (profile && profile.name) { + userName = profile.name; + } else { + console.debug(`ocLogic: Failed to resolve name for ${userId} (No name in response)`); + } + } catch (e) { + console.debug(`ocLogic: Error resolving name for ${userId}`, e); + } + } + + if (userId) { + const existing = stats[userId] || {}; + const crimeTime = calculateCrimeTimestamp(slot, crime); + + if (!crimeTime || crimeTime === 0) continue; + + // Update if this crime is newer than what we have stored + // OR if we have a better name now (and same or newer time) + const isNewer = !existing.lastSeen || crimeTime > existing.lastSeen; + const isBetterName = userName && userName !== 'Unknown' && existing.name === "Unknown"; + + if (isNewer || isBetterName) { + // Only update timestamp if it's actually newer + const newTime = isNewer ? crimeTime : existing.lastSeen; + const newCrimeId = isNewer ? crime.id : existing.lastCrimeId; + + stats[userId] = { + name: userName || existing.name || "Unknown", + lastSeen: newTime, + lastCrimeId: newCrimeId + }; + updatedUsers.add(userId); + } + } + } + } + } + return updatedUsers.size; +} + +module.exports = { + getMemberMap, + calculateCrimeTimestamp, + processCrimes +};