From 05d34af924854e999a717a6af944e2321a0d740c Mon Sep 17 00:00:00 2001 From: Kira Date: Mon, 12 Jan 2026 20:56:49 -0500 Subject: [PATCH] generate a better oc member list, and fetch it better too --- .gitignore | 3 +- commands/utility/inactive.js | 52 +++++++++++-- commands/utility/scanOC.js | 67 +++------------- index.js | 21 +++++ scripts/listOC.js | 147 +++++++++++++++++++++++++++++++++++ utils/ocLogic.js | 73 ++++++++++++++++- 6 files changed, 299 insertions(+), 64 deletions(-) create mode 100644 scripts/listOC.js diff --git a/.gitignore b/.gitignore index 7d62e3d..78e9961 100644 --- a/.gitignore +++ b/.gitignore @@ -142,4 +142,5 @@ config.json state.json docker-compose.yml public/ -data/ \ No newline at end of file +data/ +activity_visualization.md \ No newline at end of file diff --git a/commands/utility/inactive.js b/commands/utility/inactive.js index da1147d..63f7cfb 100644 --- a/commands/utility/inactive.js +++ b/commands/utility/inactive.js @@ -29,25 +29,59 @@ module.exports = { } } - let members = []; + // Fetch own faction 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.'); } + // Fetch currently active/planned/recruiting crimes to check for current participation + const activeUserIds = new Set(); + try { + const categories = ['recruiting', 'planned', 'active']; + const promises = categories.map(cat => torn.faction.crimes({ category: cat, limit: 100 })); // limit 100 to catch most + const results = await Promise.all(promises); + + results.forEach(crimes => { + if (crimes && Array.isArray(crimes)) { + crimes.forEach(crime => { + // Only consider truly active/pending statuses + const completedStatuses = ['Successful', 'Failure', 'Canceled', 'Expired', 'Timeout']; + if (completedStatuses.includes(crime.status)) { + return; + } + + if (crime.slots && Array.isArray(crime.slots)) { + crime.slots.forEach(slot => { + if (slot.user && slot.user.id) { + activeUserIds.add(slot.user.id); + } + }); + } + }); + } + }); + console.log(`inactive: Found ${activeUserIds.size} users currently in crimes.`); + } catch (e) { + console.error("inactive: Failed to fetch current crimes", e); + } + const inactiveUsers = []; // Check each member for (const member of members) { const userId = member.id; + + // Skip if user is currently in a crime + if (activeUserIds.has(userId)) continue; + const userName = member.name; const userStat = stats[userId]; - if (!userStat) { - // Never seen in tracking + if (!userStat || !userStat.lastSeen) { + // Never seen in tracking or no lastSeen data inactiveUsers.push({ id: userId, name: userName, @@ -60,6 +94,7 @@ module.exports = { id: userId, name: userName, lastSeen: new Date(userStat.lastSeen), + lastCrimeId: userStat.lastCrimeId, daysInactive: Math.floor((Date.now() - userStat.lastSeen) / (24 * 60 * 60 * 1000)) }); } @@ -92,7 +127,14 @@ module.exports = { value = "Never seen in OCs (since tracking started)"; } else { const ts = Math.floor(user.lastSeen.getTime() / 1000); - value = `Last crime: \n()`; + let dateStr = ``; + if (user.lastCrimeId) { + const url = `https://www.torn.com/factions.php?step=your&type=1#/tab=crimes&crimeId=${user.lastCrimeId}`; + dateStr = `[Last crime](${url}): `; + } else { + dateStr = `Last crime: `; + } + value = `${dateStr}\n()`; } embed.addFields({ diff --git a/commands/utility/scanOC.js b/commands/utility/scanOC.js index b6c997f..832e273 100644 --- a/commands/utility/scanOC.js +++ b/commands/utility/scanOC.js @@ -1,7 +1,7 @@ const { SlashCommandBuilder } = require('discord.js'); const fs = require('fs'); const path = require('path'); -const { getMemberMap, processCrimes } = require('../../utils/ocLogic'); +const { fetchAndProcessHistory } = require('../../utils/ocLogic'); const torn = require('../../torn.js'); module.exports = { @@ -18,68 +18,21 @@ module.exports = { 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']; + try { + const updates = await fetchAndProcessHistory(torn, statsPath, days); - 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 (updates > 0) { + await interaction.editReply(`Scan complete. Updated stats for ${updates} users.`); + } else { + await interaction.editReply(`Scan complete. No new updates needed.`); } - } - - 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.`); + } catch (e) { + console.error("scanOC: Failed to scan history", e); + await interaction.editReply(`Scan failed: ${e.message}`); } }, }; diff --git a/index.js b/index.js index 1d00fa1..dfd7c17 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,7 @@ const fs = require('fs'); const path = require('node:path'); const torn = require('./torn.js'); +const { fetchAndProcessHistory } = require('./utils/ocLogic'); const express = require('express'); let config, state; @@ -112,6 +113,26 @@ for (const folder of commandFolders) { // On client ready, generate upgrades image if missing or on first run client.on(Events.ClientReady, async () => { + // 1. Check and populate OC Stats if missing + try { + const statsPath = path.resolve(__dirname, 'data/ocStats.json'); + const dataDir = path.dirname(statsPath); + if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); + + if (!fs.existsSync(statsPath)) { + console.log('Startup: ocStats.json missing. Initiating auto-population (scanning last 90 days)...'); + // Scan 90 days by default for safety + fetchAndProcessHistory(torn, statsPath, 90).then(count => { + console.log(`Startup: Auto-population complete. Updated/Created stats for ${count} users.`); + }).catch(e => { + console.error('Startup: Auto-population failed', e); + }); + } + } catch (err) { + console.error('Startup: Error checking ocStats', err); + } + + // 2. Upgrades Image check try { const imgDir = path.resolve(__dirname, 'public'); if (!fs.existsSync(imgDir)) fs.mkdirSync(imgDir, { recursive: true }); diff --git a/scripts/listOC.js b/scripts/listOC.js new file mode 100644 index 0000000..7189595 --- /dev/null +++ b/scripts/listOC.js @@ -0,0 +1,147 @@ +// makes a list of members last OC time, outputs to activity_visualization.md + + +const torn = require('../torn.js'); +const fs = require('fs'); +const path = require('path'); + +(async () => { + try { + // Load stats + const statsPath = './data/ocStats.json'; + let stats = {}; + if (fs.existsSync(statsPath)) { + stats = JSON.parse(fs.readFileSync(statsPath, 'utf8')); + } else { + console.log("No ocStats.json found."); + } + + // Fetch members + console.log("Fetching members..."); + const members = await torn.faction.members(); + + // Fetch current crimes + console.log("Fetching current crimes..."); + const activeCrimes = new Map(); // userId -> { crimeId, category, time } + const categories = ['recruiting', 'planned', 'active']; + + const promises = categories.map(cat => torn.faction.crimes({ category: cat, limit: 100 })); + const results = await Promise.all(promises); + + results.forEach((crimes, index) => { + const cat = categories[index]; + if (crimes && Array.isArray(crimes)) { + crimes.forEach(c => { + const completed = ['Successful', 'Failure', 'Canceled', 'Expired', 'Timeout']; + // We catch everything but flag status for visualization + // But if we want to mimic inactive.js strict active check: + // Only treat as 'current' if NOT completed. + + // Actually, for visualization, let's keep the record but mark it differently? + // The user wants to see "Stage". + // If it is completed, it should be treated as "Historic" essentially, logic-wise for "Active" label. + + if (c.slots && Array.isArray(c.slots)) { + c.slots.forEach(s => { + if (s.user && s.user.id) { + const newStatus = c.status; + const newIsCompleted = completed.includes(newStatus); + + const existing = activeCrimes.get(s.user.id); + if (existing && !existing.isCompleted && newIsCompleted) { + // Existing is active, new is completed. Do NOT overwrite. + return; + } + + activeCrimes.set(s.user.id, { + crimeId: c.id, + category: cat, + status: newStatus, + started: c.time_started || c.initiated_at || c.created_at, + isCompleted: newIsCompleted + }); + } + }); + } + }); + } + }); + + let output = "# Activity Visualization\n\n"; + output += "| Name | Stage | Last Time | Details |\n"; + output += "|---|---|---|---|\n"; + + // Calculate latestTime for everyone first to allow sorting + const memberData = members.map(m => { + const stat = stats[m.id]; + const current = activeCrimes.get(m.id); + + const currentStart = current ? current.started * 1000 : 0; + const lastSeen = stat ? stat.lastSeen : 0; + const latestTime = Math.max(currentStart, lastSeen); + + return { m, stat, current, latestTime }; + }); + + // Sort: Longest time ago (smallest timestamp) first + memberData.sort((a, b) => { + if (a.latestTime === 0 && b.latestTime === 0) return 0; + if (a.latestTime === 0) return -1; // Keep members with no activity at the top (or bottom, depending on desired order) + if (b.latestTime === 0) return 1; + return a.latestTime - b.latestTime; + }); + + memberData.forEach(({ m, stat, current, latestTime }) => { + + let stage = "Unknown"; + let timeStr = "Never"; + let details; + + const isActuallyActive = current && !current.isCompleted; + + // Helper to linkify ID + const linkify = (id) => `[${id}](https://www.torn.com/factions.php?step=your&type=1#/tab=crimes&crimeId=${id})`; + + // Determine Stage and Details string + if (isActuallyActive) { + stage = `**${current.status}**`; + details = `In: ${linkify(current.crimeId)}`; + } else if (current && current.isCompleted) { + // It was found in API but is completed + stage = `${current.status}`; + details = `Done: ${linkify(current.crimeId)}`; + } else if (stat) { + // Historic + stage = "Historic"; + const diff = Date.now() - stat.lastSeen; + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + if (days < 3) stage = "Recent"; + else if (days > 7) stage = "Inactive"; + + details = `Last: ${stat.lastCrimeId ? linkify(stat.lastCrimeId) : '?'}`; + } else { + stage = "No Data"; + } + + if (latestTime > 0) { + const date = new Date(latestTime); + timeStr = date.toLocaleString(); + + // Add relative time + const diff = Date.now() - latestTime; + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + if (days === 0) timeStr += " (Today)"; + else if (days === 1) timeStr += " (Yesterday)"; + else timeStr += ` (${days} days ago)`; + } + + output += `| ${m.name} | ${stage} | ${timeStr} | ${details} |\n`; + }); + + fs.writeFileSync('activity_visualization.md', output, 'utf8'); + console.log("Written output to activity_visualization.md"); + + } catch (e) { + console.error("Error:", e); + } +})(); diff --git a/utils/ocLogic.js b/utils/ocLogic.js index 7fa6b6b..b1ccb33 100644 --- a/utils/ocLogic.js +++ b/utils/ocLogic.js @@ -105,8 +105,79 @@ async function processCrimes(crimesList, stats, memberMap, torn) { return updatedUsers.size; } +/** + * Fetches historical OCs and updates the stats file. + * @param {Object} torn The torn wrapper instance. + * @param {string} statsPath Path to the stats JSON file. + * @param {number} days Number of days back to scan. + * @returns {Promise} Number of users updated. + */ +async function fetchAndProcessHistory(torn, statsPath, days) { + const now = Date.now(); + const fromTimestamp = Math.floor((now - (days * 24 * 60 * 60 * 1000)) / 1000); + + // Load existing stats + let stats = {}; + if (fs.existsSync(statsPath)) { + try { + stats = JSON.parse(fs.readFileSync(statsPath, 'utf8')); + } catch (e) { + console.error("ocLogic: Failed to load existing stats during fetch", e); + } + } + + // Fetch faction members + const memberMap = await getMemberMap(torn); + + let crimesList = []; + const categories = ['recruiting', 'planned', 'active', 'successful', 'failed']; + + console.debug(`ocLogic: Scanning history for last ${days} days...`); + + for (const cat of categories) { + try { + const crimes = await torn.faction.crimes({ + from: fromTimestamp, + sort: 'ASC', + category: cat, + limit: 300 // Match scanoc batch size + }); + + if (crimes && Array.isArray(crimes)) { + crimesList = crimesList.concat(crimes); + } + } catch (e) { + console.error(`ocLogic: Failed to fetch crimes for category '${cat}'`, e); + } + } + + if (crimesList.length === 0) { + return 0; + } + + // Process crimes + 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)); + console.log(`ocLogic: Updated history for ${updates} users.`); + } catch (e) { + console.error("ocLogic: Failed to save stats", e); + } + } + + return updates; +} + module.exports = { getMemberMap, calculateCrimeTimestamp, - processCrimes + processCrimes, + fetchAndProcessHistory };