Compare commits
10 Commits
f013fd7b9f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a3178d002 | |||
| fce0905435 | |||
| 05d34af924 | |||
| ee6dbc1df2 | |||
| 3b8aeff340 | |||
| d00d5bb313 | |||
| b222d4a5d7 | |||
| 091bea3aed | |||
| 7636c7cf03 | |||
| 90e761de7b |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -140,6 +140,7 @@ dist
|
|||||||
|
|
||||||
config.json
|
config.json
|
||||||
state.json
|
state.json
|
||||||
cache.json
|
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
public/
|
public/
|
||||||
|
data/
|
||||||
|
activity_visualization.md
|
||||||
@@ -5,13 +5,13 @@ module.exports = {
|
|||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
.setName('profile')
|
.setName('profile')
|
||||||
.setDescription('Get your Torn profile')
|
.setDescription('Get your Torn profile')
|
||||||
.addIntegerOption(option =>
|
.addIntegerOption(option =>
|
||||||
option.setName('id')
|
option.setName('id')
|
||||||
.setDescription('User ID')),
|
.setDescription('User ID')),
|
||||||
async execute(interaction) {
|
async execute(interaction) {
|
||||||
let id
|
let id
|
||||||
if (!interaction.options.getInteger('id')) {
|
if (!interaction.options.getInteger('id')) {
|
||||||
id = await torn.self.id()
|
id = torn.self.player_id
|
||||||
console.log(`Profile: Looking up "${id}"`)
|
console.log(`Profile: Looking up "${id}"`)
|
||||||
} else {
|
} else {
|
||||||
id = interaction.options.getInteger('id');
|
id = interaction.options.getInteger('id');
|
||||||
|
|||||||
59
commands/bot/crimeNotify.js
Normal file
59
commands/bot/crimeNotify.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
const { SlashCommandBuilder, MessageFlags } = require('discord.js');
|
||||||
|
const config = require('../../config.json');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('crimenotify')
|
||||||
|
.setDescription('Options for crime level empty alerts')
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand.setName('set')
|
||||||
|
.setDescription('Decide if a specific crime level will trigger an alert when empty')
|
||||||
|
.addIntegerOption(option =>
|
||||||
|
option.setName('level')
|
||||||
|
.setDescription('The crime level to set the alert for.')
|
||||||
|
.setRequired(true)
|
||||||
|
.setMinValue(1)
|
||||||
|
.setMaxValue(10))
|
||||||
|
.addBooleanOption(option =>
|
||||||
|
option.setName('notify')
|
||||||
|
.setDescription('Whether to notify when this crime level is empty.')
|
||||||
|
.setRequired(true))
|
||||||
|
)
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand.setName('list')
|
||||||
|
.setDescription('List all crime levels and whether they trigger an alert when empty.')
|
||||||
|
),
|
||||||
|
async execute(interaction) {
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
if (subcommand === 'list') {
|
||||||
|
let message = 'Crime levels and whether they trigger an alert when empty:\n';
|
||||||
|
if (config.crimeNotify) {
|
||||||
|
for (const level in config.crimeNotify) {
|
||||||
|
message += `Crime level ${level}/10 will ${config.crimeNotify[level] ? 'notify' : 'not notify'} when empty.\n`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message += 'No alert overrides set.\n';
|
||||||
|
}
|
||||||
|
await interaction.reply({ content: message, flags: MessageFlags.Ephemeral });
|
||||||
|
} else if (subcommand === 'set') {
|
||||||
|
const level = interaction.options.getInteger('level');
|
||||||
|
const notify = interaction.options.getBoolean('notify');
|
||||||
|
|
||||||
|
if (!config.crimeNotify) {
|
||||||
|
config.crimeNotify = {};
|
||||||
|
}
|
||||||
|
config.crimeNotify[level] = notify;
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(path.join(__dirname, '../../config.json'), JSON.stringify(config, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save config.json:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({ content: `Crime level ${level}/10 will ${notify ? 'notify' : 'not notify'} when empty.`, flags: MessageFlags.Ephemeral });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
const { SlashCommandBuilder } = require('discord.js');
|
const { SlashCommandBuilder, MessageFlags } = require('discord.js');
|
||||||
const torn = require('../../torn.js');
|
const torn = require('../../torn.js');
|
||||||
const config = require('../../config.json');
|
const config = require('../../config.json');
|
||||||
const state = require('../../state.json');
|
const state = require('../../state.json');
|
||||||
@@ -16,17 +16,17 @@ module.exports = {
|
|||||||
const task = interaction.client.tasks[taskName];
|
const task = interaction.client.tasks[taskName];
|
||||||
|
|
||||||
if (!task) {
|
if (!task) {
|
||||||
await interaction.reply({ content: `Task "${taskName}" not found.`, ephemeral: true });
|
await interaction.reply({ content: `Task "${taskName}" not found.`, flags: MessageFlags.Ephemeral });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await interaction.reply({ content: `Executing task "${taskName}"...`, ephemeral: true });
|
await interaction.reply({ content: `Executing task "${taskName}"...`, flags: MessageFlags.Ephemeral });
|
||||||
await task(interaction.client, torn, config, state);
|
await task(interaction.client, torn, config, state);
|
||||||
await interaction.followUp({ content: `Task "${taskName}" executed successfully.`, ephemeral: true });
|
await interaction.followUp({ content: `Task "${taskName}" executed successfully.`, flags: MessageFlags.Ephemeral });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
await interaction.followUp({ content: `There was an error while executing task "${taskName}"!`, ephemeral: true });
|
await interaction.followUp({ content: `There was an error while executing task "${taskName}"!`, flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
const { SlashCommandBuilder, EmbedBuilder } = require('discord.js');
|
const { SlashCommandBuilder, EmbedBuilder, MessageFlags } = require('discord.js');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -8,7 +8,7 @@ module.exports = {
|
|||||||
const taskNames = Object.keys(interaction.client.tasks);
|
const taskNames = Object.keys(interaction.client.tasks);
|
||||||
|
|
||||||
if (taskNames.length === 0) {
|
if (taskNames.length === 0) {
|
||||||
await interaction.reply({ content: 'No tasks found.', ephemeral: true });
|
await interaction.reply({ content: 'No tasks found.', flags: MessageFlags.Ephemeral });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,6 +17,6 @@ module.exports = {
|
|||||||
.setTitle('Available Tasks')
|
.setTitle('Available Tasks')
|
||||||
.setDescription(taskNames.map(name => `- ${name}`).join('\n'));
|
.setDescription(taskNames.map(name => `- ${name}`).join('\n'));
|
||||||
|
|
||||||
await interaction.reply({ embeds: [embed], ephemeral: true });
|
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -7,14 +7,17 @@ module.exports = {
|
|||||||
.setDescription('How is KZNKing doing'),
|
.setDescription('How is KZNKing doing'),
|
||||||
async execute(interaction) {
|
async execute(interaction) {
|
||||||
const kznID = 3392710
|
const kznID = 3392710
|
||||||
const KZNKing = await torn.user.profile(kznID);
|
const data = await torn.user.get(kznID, ['profile', 'job', 'faction']);
|
||||||
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 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 = [];
|
const embeds = [];
|
||||||
if (KZNKing.job.position === "Director") {
|
if (KZNKing.job.position === "Director") {
|
||||||
const jobEmbed = new EmbedBuilder()
|
const jobEmbed = new EmbedBuilder()
|
||||||
@@ -60,35 +63,35 @@ module.exports = {
|
|||||||
embeds.push(jobEmbed);
|
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") {
|
if (KZNKing.faction.position === "Leader") {
|
||||||
const facEmbed = new EmbedBuilder()
|
const facEmbed = new EmbedBuilder()
|
||||||
.setTitle(faction.name)
|
.setTitle(factionBasic.name)
|
||||||
.setURL(`https://www.torn.com/factions.php?step=profile&ID=${faction.id}`)
|
.setURL(`https://www.torn.com/factions.php?step=profile&ID=${factionBasic.id}`)
|
||||||
.addFields(
|
.addFields(
|
||||||
{
|
{
|
||||||
name: "Members",
|
name: "Members",
|
||||||
value: `${faction.members}/${faction.capacity}`,
|
value: `${factionBasic.members}/${factionBasic.capacity}`,
|
||||||
inline: true
|
inline: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Rank",
|
name: "Rank",
|
||||||
value: `${faction.rank.name} ${faction.rank.division}`,
|
value: `${factionBasic.rank.name} ${factionBasic.rank.division}`,
|
||||||
inline: true
|
inline: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Respect",
|
name: "Respect",
|
||||||
value: `${faction.respect.toLocaleString()}`,
|
value: `${factionBasic.respect.toLocaleString()}`,
|
||||||
inline: true
|
inline: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Age",
|
name: "Age",
|
||||||
value: `${faction.days_old}`,
|
value: `${factionBasic.days_old}`,
|
||||||
inline: true
|
inline: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Wars Won",
|
name: "Wars Won",
|
||||||
value: `${faction.rank.wins}`,
|
value: `${factionBasic.rank.wins}`,
|
||||||
inline: true
|
inline: true
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -98,7 +101,7 @@ module.exports = {
|
|||||||
let companyFemales = 0;
|
let companyFemales = 0;
|
||||||
let companyTotal = 0;
|
let companyTotal = 0;
|
||||||
const companyFemalePromises = Object.entries(company.employees).map(([user]) => {
|
const companyFemalePromises = Object.entries(company.employees).map(([user]) => {
|
||||||
return torn.cache.user(user).then(data => {
|
return torn.user.basic(user).then(data => {
|
||||||
companyTotal++;
|
companyTotal++;
|
||||||
if (data.gender === "Female") {
|
if (data.gender === "Female") {
|
||||||
companyFemales++;
|
companyFemales++;
|
||||||
@@ -108,9 +111,9 @@ module.exports = {
|
|||||||
|
|
||||||
let factionFemales = 0;
|
let factionFemales = 0;
|
||||||
let factionTotal = 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) => {
|
const factionFemalePromises = factionMembers.map((user) => {
|
||||||
return torn.cache.user(user.id).then(data => {
|
return torn.user.basic(user.id).then(data => {
|
||||||
factionTotal++;
|
factionTotal++;
|
||||||
if (data.gender === "Female") {
|
if (data.gender === "Female") {
|
||||||
factionFemales++;
|
factionFemales++;
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ module.exports = {
|
|||||||
.setDescription('Calculate war payout based on participation')
|
.setDescription('Calculate war payout based on participation')
|
||||||
.addIntegerOption(option =>
|
.addIntegerOption(option =>
|
||||||
option.setName('total')
|
option.setName('total')
|
||||||
.setDescription('Full war earnings total before cuts')
|
.setDescription('Full war earnings total before cuts (Optional)')
|
||||||
.setRequired(true))
|
.setRequired(false))
|
||||||
.addIntegerOption(option =>
|
.addIntegerOption(option =>
|
||||||
option.setName('percentage')
|
option.setName('percentage')
|
||||||
.setDescription('Percentage of leader cut (default 10)'))
|
.setDescription('Percentage of leader cut (default 10)'))
|
||||||
@@ -21,14 +21,10 @@ module.exports = {
|
|||||||
{ name: 'Attack Based', value: 'attacks' },
|
{ name: 'Attack Based', value: 'attacks' },
|
||||||
)),
|
)),
|
||||||
async execute(interaction) {
|
async execute(interaction) {
|
||||||
const total = interaction.options.getInteger('total');
|
let total = interaction.options.getInteger('total');
|
||||||
const percentage = interaction.options.getInteger('percentage') ?? 10;
|
const percentage = interaction.options.getInteger('percentage') ?? 10;
|
||||||
const method = interaction.options.getString('method') ?? 'flat';
|
const method = interaction.options.getString('method') ?? 'flat';
|
||||||
|
|
||||||
// Calculate cuts
|
|
||||||
const leaderCut = Math.ceil(total * (percentage / 100));
|
|
||||||
const pool = total - leaderCut;
|
|
||||||
|
|
||||||
await interaction.deferReply();
|
await interaction.deferReply();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -44,6 +40,39 @@ module.exports = {
|
|||||||
return interaction.editReply('Could not find our faction in the last war report.');
|
return interaction.editReply('Could not find our faction in the last war report.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-calculate total if not provided
|
||||||
|
if (!total) {
|
||||||
|
let calculatedTotal = 0;
|
||||||
|
const rewards = ourFaction.rewards;
|
||||||
|
|
||||||
|
if (rewards && rewards.items) {
|
||||||
|
const itemIds = Array.isArray(rewards.items)
|
||||||
|
? rewards.items.map(i => i.id || i.ID)
|
||||||
|
: Object.keys(rewards.items);
|
||||||
|
|
||||||
|
for (const itemId of itemIds) {
|
||||||
|
const qt = Array.isArray(rewards.items)
|
||||||
|
? rewards.items.find(i => (i.id == itemId || i.ID == itemId)).quantity
|
||||||
|
: rewards.items[itemId];
|
||||||
|
|
||||||
|
const itemData = await torn.item(itemId, true);
|
||||||
|
if (itemData && itemData.value && itemData.value.market_price) {
|
||||||
|
calculatedTotal += itemData.value.market_price * qt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (calculatedTotal > 0) {
|
||||||
|
total = calculatedTotal;
|
||||||
|
} else {
|
||||||
|
return interaction.editReply('No total provided and could not calculate rewards from the war report.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate cuts
|
||||||
|
const leaderCut = Math.ceil(total * (percentage / 100));
|
||||||
|
const pool = total - leaderCut;
|
||||||
|
|
||||||
const members = ourFaction.members;
|
const members = ourFaction.members;
|
||||||
const participants = [];
|
const participants = [];
|
||||||
const nonParticipants = [];
|
const nonParticipants = [];
|
||||||
@@ -77,7 +106,7 @@ module.exports = {
|
|||||||
|
|
||||||
|
|
||||||
let message = `# War Payout: ${ourFaction.name} vs ${enemyFaction.name}\n`;
|
let message = `# War Payout: ${ourFaction.name} vs ${enemyFaction.name}\n`;
|
||||||
message += `**Total Earnings:** $${total.toLocaleString()}\n`;
|
message += `**Total Earnings:** $${total.toLocaleString()}${!interaction.options.getInteger('total') ? ' (Auto-Calculated)' : ''}\n`;
|
||||||
message += `**Leader Cut (${percentage}%):** $${leaderCut.toLocaleString()} (Yameii)\n`;
|
message += `**Leader Cut (${percentage}%):** $${leaderCut.toLocaleString()} (Yameii)\n`;
|
||||||
message += `**Distributable Pool:** $${pool.toLocaleString()}\n`;
|
message += `**Distributable Pool:** $${pool.toLocaleString()}\n`;
|
||||||
|
|
||||||
|
|||||||
154
commands/utility/inactive.js
Normal file
154
commands/utility/inactive.js
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch own faction members
|
||||||
|
try {
|
||||||
|
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 || !userStat.lastSeen) {
|
||||||
|
// Never seen in tracking or no lastSeen data
|
||||||
|
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),
|
||||||
|
lastCrimeId: userStat.lastCrimeId,
|
||||||
|
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);
|
||||||
|
let dateStr = `<t:${ts}:d>`;
|
||||||
|
if (user.lastCrimeId) {
|
||||||
|
const url = `https://www.torn.com/factions.php?step=your&type=1#/tab=crimes&crimeId=${user.lastCrimeId}`;
|
||||||
|
dateStr = `[Last crime](${url}): <t:${ts}:d>`;
|
||||||
|
} else {
|
||||||
|
dateStr = `Last crime: <t:${ts}:d>`;
|
||||||
|
}
|
||||||
|
value = `${dateStr}\n(<t:${ts}:R>)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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] });
|
||||||
|
},
|
||||||
|
};
|
||||||
38
commands/utility/scanOC.js
Normal file
38
commands/utility/scanOC.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
const { SlashCommandBuilder } = require('discord.js');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { fetchAndProcessHistory } = 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 statsPath = path.join(__dirname, '../../data/ocStats.json');
|
||||||
|
|
||||||
|
await interaction.editReply(`Scanning OCs from the last ${days} days...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updates = await fetchAndProcessHistory(torn, statsPath, days);
|
||||||
|
|
||||||
|
if (updates > 0) {
|
||||||
|
await interaction.editReply(`Scan complete. Updated stats for ${updates} users.`);
|
||||||
|
} else {
|
||||||
|
await interaction.editReply(`Scan complete. No new updates needed.`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("scanOC: Failed to scan history", e);
|
||||||
|
await interaction.editReply(`Scan failed: ${e.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
const { SlashCommandBuilder } = require('discord.js');
|
const { SlashCommandBuilder } = require('discord.js');
|
||||||
const torn = require('../../torn.js');
|
const torn = require('../../torn.js');
|
||||||
const config = require('../../config.json');
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
// eslint-disable-next-line no-unused-vars
|
const renderer = require('../../utils/UpgradeRenderer.js');
|
||||||
const { createCanvas, registerFont } = require('canvas');
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -12,184 +10,24 @@ module.exports = {
|
|||||||
.setDescription('Generate the faction upgrades PNG'),
|
.setDescription('Generate the faction upgrades PNG'),
|
||||||
|
|
||||||
async execute(interaction) {
|
async execute(interaction) {
|
||||||
await interaction.deferReply(); // give more time for image generation
|
await interaction.deferReply();
|
||||||
|
|
||||||
const data = await torn.faction.upgrades();
|
|
||||||
|
|
||||||
// Build lines with group metadata (core / peace / war)
|
|
||||||
const lines = []; // { text: string, group: 'core'|'peace'|'war'|null }
|
|
||||||
lines.push({ text: 'Core Upgrades', group: 'core' });
|
|
||||||
let armoryNames = [];
|
|
||||||
for (const upgrade of data.upgrades.core.upgrades) {
|
|
||||||
if (upgrade.name && String(upgrade.name).toLowerCase().includes('armory')) {
|
|
||||||
armoryNames.push(upgrade.name.replace(/\s+armory$/i, ''));
|
|
||||||
} else {
|
|
||||||
lines.push({ text: ` ${upgrade.name} - ${upgrade.ability}`, group: 'core' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (armoryNames.length) {
|
|
||||||
lines.push({ text: ` Armory: ${armoryNames.join(', ')}`, group: 'core' });
|
|
||||||
}
|
|
||||||
lines.push({ text: '', group: null });
|
|
||||||
|
|
||||||
lines.push({ text: 'Peace Upgrades', group: 'peace' });
|
|
||||||
for (const branch of data.upgrades.peace) {
|
|
||||||
lines.push({ text: ` ${branch.name}`, group: 'peace' });
|
|
||||||
for (const upgrade of branch.upgrades) {
|
|
||||||
lines.push({ text: ` ${upgrade.name} - ${upgrade.ability}`, group: 'peace' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines.push({ text: '', group: null });
|
|
||||||
|
|
||||||
lines.push({ text: 'War Upgrades', group: 'war' });
|
|
||||||
for (const branch of data.upgrades.war) {
|
|
||||||
lines.push({ text: ` ${branch.name}`, group: 'war' });
|
|
||||||
for (const upgrade of branch.upgrades) {
|
|
||||||
lines.push({ text: ` ${upgrade.name} - ${upgrade.ability}`, group: 'war' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Image rendering settings
|
|
||||||
const padding = 24;
|
|
||||||
const maxWidth = 1100;
|
|
||||||
const fontSize = 18;
|
|
||||||
const fontFamily = 'Sans';
|
|
||||||
const fontSpec = `${fontSize}px ${fontFamily}`;
|
|
||||||
|
|
||||||
// Temporary canvas for measurement
|
|
||||||
const measureCanvas = createCanvas(10, 10);
|
|
||||||
const measureCtx = measureCanvas.getContext('2d');
|
|
||||||
measureCtx.font = fontSpec;
|
|
||||||
|
|
||||||
function wrapLine(ctx, text, maxW) {
|
|
||||||
const words = text.split(' ');
|
|
||||||
const wrapped = [];
|
|
||||||
let line = '';
|
|
||||||
for (const word of words) {
|
|
||||||
const test = line ? `${line} ${word}` : word;
|
|
||||||
const w = ctx.measureText(test).width;
|
|
||||||
if (w > maxW && line) {
|
|
||||||
wrapped.push(line);
|
|
||||||
line = word;
|
|
||||||
} else {
|
|
||||||
line = test;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (line) wrapped.push(line);
|
|
||||||
return wrapped;
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseColors = {
|
|
||||||
core: config.upgradeColors.core,
|
|
||||||
peace: config.upgradeColors.peace,
|
|
||||||
peaceDim: config.upgradeColors.peaceDim,
|
|
||||||
war: config.upgradeColors.war,
|
|
||||||
warDim: config.upgradeColors.warDim
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const state = (data.state || '').toLowerCase();
|
|
||||||
let dimMode = 'desaturate';
|
|
||||||
let inactiveGroup = null;
|
|
||||||
if (state === 'peace') {
|
|
||||||
dimMode = 'opacity';
|
|
||||||
inactiveGroup = 'war';
|
|
||||||
} else if (state === 'war') {
|
|
||||||
dimMode = 'opacity';
|
|
||||||
inactiveGroup = 'peace';
|
|
||||||
}
|
|
||||||
|
|
||||||
function colorForGroup(group) {
|
|
||||||
if (!group) return '#ffffff';
|
|
||||||
if (group === 'core') return baseColors.core;
|
|
||||||
|
|
||||||
if (dimMode === 'opacity') {
|
|
||||||
if (group === inactiveGroup) return group === 'peace' ? baseColors.peaceDim : baseColors.warDim;
|
|
||||||
return group === 'peace' ? baseColors.peace : baseColors.war;
|
|
||||||
} else {
|
|
||||||
// fallback darker variants when state is unknown
|
|
||||||
return group === 'peace' ? baseColors.peaceDim : baseColors.warDim;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap and measure lines while preserving group and level
|
|
||||||
const fontSizes = { 0: 26, 1: 22, 2: 18 };
|
|
||||||
const lineHeightFactor = 1.3;
|
|
||||||
|
|
||||||
let visualLines = []; // { text, group, level, fontSize, lineHeight }
|
|
||||||
let measuredMaxWidth = 0;
|
|
||||||
const textMaxWidth = maxWidth - padding * 2;
|
|
||||||
for (const ln of lines) {
|
|
||||||
if (!ln.text) {
|
|
||||||
visualLines.push({ text: '', group: null, level: 0, fontSize: fontSizes[0], lineHeight: Math.round(fontSizes[0] * lineHeightFactor) });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const leading = (ln.text.match(/^ */) || [''])[0].length;
|
|
||||||
let level = Math.min(2, Math.floor(leading / 2));
|
|
||||||
// Use smallest font size for core upgrade list items (they are indented)
|
|
||||||
if (ln.group === 'core' && level === 1) level = 2;
|
|
||||||
const rawText = ln.text.trim();
|
|
||||||
const fsz = fontSizes[level] || fontSize;
|
|
||||||
|
|
||||||
measureCtx.font = `${fsz}px ${fontFamily}`;
|
|
||||||
const wrapped = wrapLine(measureCtx, rawText, textMaxWidth);
|
|
||||||
for (const wln of wrapped) {
|
|
||||||
const w = Math.ceil(measureCtx.measureText(wln).width);
|
|
||||||
visualLines.push({ text: wln, group: ln.group, level, fontSize: fsz, lineHeight: Math.round(fsz * lineHeightFactor) });
|
|
||||||
measuredMaxWidth = Math.max(measuredMaxWidth, w);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const canvasWidth = Math.min(maxWidth, measuredMaxWidth + padding * 2);
|
|
||||||
const canvasHeight = padding * 2 + visualLines.reduce((sum, l) => sum + l.lineHeight, 0);
|
|
||||||
|
|
||||||
const canvas = createCanvas(canvasWidth, canvasHeight);
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
|
|
||||||
// rounded corners, if you think i wouldnt have an ai do this for me youre silly
|
|
||||||
const cornerRadius = 24;
|
|
||||||
ctx.fillStyle = config.upgradeColors.background;
|
|
||||||
(function roundedRect(ctx, x, y, w, h, r) {
|
|
||||||
const radius = Math.max(0, Math.min(r, Math.min(w / 2, h / 2)));
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x + radius, y);
|
|
||||||
ctx.lineTo(x + w - radius, y);
|
|
||||||
ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
|
|
||||||
ctx.lineTo(x + w, y + h - radius);
|
|
||||||
ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
|
|
||||||
ctx.lineTo(x + radius, y + h);
|
|
||||||
ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
|
|
||||||
ctx.lineTo(x, y + radius);
|
|
||||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fill();
|
|
||||||
})(ctx, 0, 0, canvasWidth, canvasHeight, cornerRadius);
|
|
||||||
|
|
||||||
ctx.textBaseline = 'top';
|
|
||||||
|
|
||||||
let y = padding;
|
|
||||||
for (const vln of visualLines) {
|
|
||||||
ctx.font = `${vln.fontSize}px ${fontFamily}`;
|
|
||||||
ctx.fillStyle = colorForGroup(vln.group);
|
|
||||||
const textWidth = Math.ceil(ctx.measureText(vln.text).width);
|
|
||||||
const x = Math.round((canvasWidth - textWidth) / 2);
|
|
||||||
ctx.fillText(vln.text, x, y);
|
|
||||||
y += vln.lineHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
const outDir = path.resolve(__dirname, '..', '..', 'public');
|
|
||||||
fs.mkdirSync(outDir, { recursive: true });
|
|
||||||
|
|
||||||
const outFile = path.join(outDir, 'upgrades.png');
|
|
||||||
const buffer = canvas.toBuffer('image/png');
|
|
||||||
fs.writeFileSync(outFile, buffer);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const data = await torn.faction.upgrades();
|
||||||
|
const buffer = renderer.render(data);
|
||||||
|
|
||||||
|
const outDir = path.resolve(__dirname, '..', '..', 'public');
|
||||||
|
if (!fs.existsSync(outDir)) {
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const outFile = path.join(outDir, 'upgrades.png');
|
||||||
|
fs.writeFileSync(outFile, buffer);
|
||||||
|
|
||||||
await interaction.editReply({ files: [outFile] });
|
await interaction.editReply({ files: [outFile] });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await interaction.editReply('Generated upgrades image but failed to attach it.');
|
console.error('Error generating upgrades image:', err);
|
||||||
console.debug('Failed to attach image to interaction reply:', err.message || err);
|
await interaction.editReply('Failed to generate upgrades image.');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,15 +4,16 @@
|
|||||||
"alerts": "YOUR DISCORD USER ID",
|
"alerts": "YOUR DISCORD USER ID",
|
||||||
"torn": "TORN API KEY",
|
"torn": "TORN API KEY",
|
||||||
"httpPort": 3000,
|
"httpPort": 3000,
|
||||||
|
"taskWaitMinutes": 5,
|
||||||
"channels": {
|
"channels": {
|
||||||
"ocAlert": "000000000000000000"
|
"ocAlert": "000000000000000000"
|
||||||
},
|
},
|
||||||
"upgradeColors": {
|
"upgradeColors": {
|
||||||
"core": "#FFFFFF",
|
"core": "#FFFFFF",
|
||||||
"peace": "#FFFFFF",
|
"peace": "#FFFFFF",
|
||||||
"peaceDim": "#AAAAAA",
|
"peaceDim": "#AAAAAA",
|
||||||
"war": "#FFFFFF",
|
"war": "#FFFFFF",
|
||||||
"warDim": "#AAAAAA",
|
"warDim": "#AAAAAA",
|
||||||
"background": "#0A2472"
|
"background": "#0A2472"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
86
index.js
86
index.js
@@ -1,7 +1,8 @@
|
|||||||
const cron = require('node-cron');
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const torn = require('./torn.js');
|
const torn = require('./torn.js');
|
||||||
|
const { fetchAndProcessHistory } = require('./utils/ocLogic');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
|
||||||
let config, state;
|
let config, state;
|
||||||
@@ -19,9 +20,9 @@ try {
|
|||||||
} catch {
|
} catch {
|
||||||
console.log("Core: No state file found, creating one.")
|
console.log("Core: No state file found, creating one.")
|
||||||
state = {
|
state = {
|
||||||
"ocAlertLast": "2025-01-01T00:00:00.000Z",
|
"ocAlertLast": "2025-01-01T00:00:00.000Z",
|
||||||
"payoutAlertLast": "2025-01-01T00:00:00.000Z",
|
"payoutAlertLast": "2025-01-01T00:00:00.000Z",
|
||||||
"itemAlertLast": "2025-01-01T00:00:00.000Z"
|
"itemAlertLast": "2025-01-01T00:00:00.000Z"
|
||||||
}
|
}
|
||||||
fs.writeFileSync('./state.json', JSON.stringify(state));
|
fs.writeFileSync('./state.json', JSON.stringify(state));
|
||||||
stateWasCreated = true;
|
stateWasCreated = true;
|
||||||
@@ -32,19 +33,19 @@ try {
|
|||||||
const { Client, Collection, Events, GatewayIntentBits, EmbedBuilder, Partials, MessageFlags } = require('discord.js');
|
const { Client, Collection, Events, GatewayIntentBits, EmbedBuilder, Partials, MessageFlags } = require('discord.js');
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
intents: [
|
intents: [
|
||||||
GatewayIntentBits.Guilds,
|
GatewayIntentBits.Guilds,
|
||||||
GatewayIntentBits.GuildMessages,
|
GatewayIntentBits.GuildMessages,
|
||||||
GatewayIntentBits.DirectMessages,
|
GatewayIntentBits.DirectMessages,
|
||||||
GatewayIntentBits.MessageContent
|
GatewayIntentBits.MessageContent
|
||||||
],
|
],
|
||||||
partials: [
|
partials: [
|
||||||
Partials.Channel,
|
Partials.Channel,
|
||||||
Partials.Message
|
Partials.Message
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
client.once(Events.ClientReady, readyClient => {
|
client.once(Events.ClientReady, readyClient => {
|
||||||
console.log(`Discord: Connected as ${readyClient.user.tag}`);
|
console.log(`Discord: Connected as ${readyClient.user.tag}`);
|
||||||
torn.readyCheck(config.torn);
|
torn.readyCheck();
|
||||||
});
|
});
|
||||||
client.login(config.token);
|
client.login(config.token);
|
||||||
client.commands = new Collection();
|
client.commands = new Collection();
|
||||||
@@ -52,23 +53,50 @@ client.tasks = {};
|
|||||||
|
|
||||||
fs.readdir('./tasks/', (err, files) => {
|
fs.readdir('./tasks/', (err, files) => {
|
||||||
if (err) return console.log(err);
|
if (err) return console.log(err);
|
||||||
|
const taskNames = [];
|
||||||
files.forEach(file => {
|
files.forEach(file => {
|
||||||
const taskFile = require(`./tasks/${file}`);
|
const taskFile = require(`./tasks/${file}`);
|
||||||
const taskName = file.split('.')[0];
|
const taskName = file.split('.')[0];
|
||||||
client.tasks[taskName] = taskFile;
|
client.tasks[taskName] = taskFile;
|
||||||
if (taskFile.schedule) {
|
taskNames.push(taskName);
|
||||||
console.debug(`Tasks: Scheduling "${taskName}" for ${taskFile.schedule}`);
|
console.debug(`Tasks: Registered "${taskName}"`);
|
||||||
cron.schedule(taskFile.schedule, () => { taskFile(client, torn, config, state); });
|
|
||||||
} else {
|
|
||||||
console.debug(`Tasks: Registered "${taskName}"`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Round-robin scheduler
|
||||||
|
let currentTaskIndex = 0;
|
||||||
|
const runNextTask = () => {
|
||||||
|
if (taskNames.length === 0) return;
|
||||||
|
|
||||||
|
const taskName = taskNames[currentTaskIndex];
|
||||||
|
const taskFile = client.tasks[taskName];
|
||||||
|
const now = new Date();
|
||||||
|
const dateString = now.toLocaleTimeString('en-US', { hour12: false }) + ' ' + now.toLocaleDateString('en-US');
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.debug(`Tasks: Executing "${taskName}" at ${dateString}`);
|
||||||
|
taskFile(client, torn, config, state);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Tasks: Error executing "${taskName}" at ${dateString}:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTaskIndex = (currentTaskIndex + 1) % taskNames.length;
|
||||||
|
|
||||||
|
const waitMinutes = config.taskWaitMinutes || 5;
|
||||||
|
setTimeout(runNextTask, waitMinutes * 60 * 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start the loop with an initial delay
|
||||||
|
if (taskNames.length > 0) {
|
||||||
|
const waitMinutes = config.taskWaitMinutes || 5;
|
||||||
|
console.log(`Tasks: Scheduler started. First task will run in ${waitMinutes} minutes.`);
|
||||||
|
setTimeout(runNextTask, waitMinutes * 60 * 1000);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// discord command stuff also yoinked
|
// discord command stuff also yoinked
|
||||||
const foldersPath = path.join(__dirname, 'commands');
|
const foldersPath = path.join(__dirname, 'commands');
|
||||||
const commandFolders = fs.readdirSync(foldersPath);
|
const commandFolders = fs.readdirSync(foldersPath);
|
||||||
for (const folder of commandFolders) {
|
for (const folder of commandFolders) {
|
||||||
const commandsPath = path.join(foldersPath, folder);
|
const commandsPath = path.join(foldersPath, folder);
|
||||||
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
|
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
|
||||||
for (const file of commandFiles) {
|
for (const file of commandFiles) {
|
||||||
@@ -85,6 +113,26 @@ const commandFolders = fs.readdirSync(foldersPath);
|
|||||||
|
|
||||||
// On client ready, generate upgrades image if missing or on first run
|
// On client ready, generate upgrades image if missing or on first run
|
||||||
client.on(Events.ClientReady, async () => {
|
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 {
|
try {
|
||||||
const imgDir = path.resolve(__dirname, 'public');
|
const imgDir = path.resolve(__dirname, 'public');
|
||||||
if (!fs.existsSync(imgDir)) fs.mkdirSync(imgDir, { recursive: true });
|
if (!fs.existsSync(imgDir)) fs.mkdirSync(imgDir, { recursive: true });
|
||||||
@@ -94,8 +142,8 @@ client.on(Events.ClientReady, async () => {
|
|||||||
if (cmd && typeof cmd.execute === 'function') {
|
if (cmd && typeof cmd.execute === 'function') {
|
||||||
console.debug('Startup: Generating upgrades image (missing or first run)');
|
console.debug('Startup: Generating upgrades image (missing or first run)');
|
||||||
const mockInteraction = {
|
const mockInteraction = {
|
||||||
deferReply: async () => {},
|
deferReply: async () => { },
|
||||||
editReply: async () => {}
|
editReply: async () => { }
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await cmd.execute(mockInteraction);
|
await cmd.execute(mockInteraction);
|
||||||
@@ -109,7 +157,7 @@ client.on(Events.ClientReady, async () => {
|
|||||||
console.error('Startup: error while ensuring upgrades image', err);
|
console.error('Startup: error while ensuring upgrades image', err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on(Events.InteractionCreate, async interaction => {
|
client.on(Events.InteractionCreate, async interaction => {
|
||||||
if (interaction.isButton()) {
|
if (interaction.isButton()) {
|
||||||
if (interaction.customId === 'delete_message') {
|
if (interaction.customId === 'delete_message') {
|
||||||
|
|||||||
24
package-lock.json
generated
24
package-lock.json
generated
@@ -12,8 +12,7 @@
|
|||||||
"canvas": "^3.2.0",
|
"canvas": "^3.2.0",
|
||||||
"discord.js": "^14.18.0",
|
"discord.js": "^14.18.0",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"fs": "^0.0.1-security",
|
"fs": "^0.0.1-security"
|
||||||
"node-cron": "^3.0.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.24.0",
|
"@eslint/js": "^9.24.0",
|
||||||
@@ -1835,18 +1834,6 @@
|
|||||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/node-cron": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"uuid": "8.3.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/object-inspect": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
@@ -2547,15 +2534,6 @@
|
|||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
|
||||||
"version": "8.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
|
||||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"uuid": "dist/bin/uuid"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
|||||||
@@ -20,8 +20,7 @@
|
|||||||
"canvas": "^3.2.0",
|
"canvas": "^3.2.0",
|
||||||
"discord.js": "^14.18.0",
|
"discord.js": "^14.18.0",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"fs": "^0.0.1-security",
|
"fs": "^0.0.1-security"
|
||||||
"node-cron": "^3.0.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.24.0",
|
"@eslint/js": "^9.24.0",
|
||||||
|
|||||||
147
scripts/listOC.js
Normal file
147
scripts/listOC.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
24
tasks/autoUpdateUpgrades.js
Normal file
24
tasks/autoUpdateUpgrades.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
module.exports = async (client, torn, config) => {
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const renderer = require('../utils/UpgradeRenderer.js');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await torn.faction.upgrades();
|
||||||
|
const buffer = renderer.render(data);
|
||||||
|
|
||||||
|
const outDir = path.resolve(__dirname, '..', 'public');
|
||||||
|
if (!fs.existsSync(outDir)) {
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const outFile = path.join(outDir, 'upgrades.png');
|
||||||
|
fs.writeFileSync(outFile, buffer);
|
||||||
|
console.debug("autoUpdateUpgrades: Successfully updated upgrades.png");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("autoUpdateUpgrades: Failed to update upgrades.png", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.schedule = '0 * * * *';
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
module.exports = async (client, torn, config) => {
|
module.exports = async (client, torn, config) => {
|
||||||
console.debug("Task: Executing noItemOC");
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const channel = client.channels.resolve(config.channels.ocAlert);
|
const channel = client.channels.resolve(config.channels.ocAlert);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const state = require('../state.json');
|
const state = require('../state.json');
|
||||||
const data = { crimes: await torn.faction.crimes({ category: 'planning', sort: 'DESC' }) };
|
const crimesList = await torn.faction.crimes({ category: 'planning', sort: 'DESC' });
|
||||||
|
if (!crimesList) {
|
||||||
|
console.error("noItemOC: API returned no crimes (check permissions/key?)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = { crimes: crimesList };
|
||||||
let itemsneeded = 0;
|
let itemsneeded = 0;
|
||||||
let message = "OCs with unavailable items:\n";
|
let message = "OCs with unavailable items:\n";
|
||||||
for (const crime of data.crimes) {
|
for (const crime of data.crimes) {
|
||||||
@@ -12,7 +17,7 @@ module.exports = async (client, torn, config) => {
|
|||||||
if (slot.item_requirement) {
|
if (slot.item_requirement) {
|
||||||
if (slot.item_requirement.is_available === false) {
|
if (slot.item_requirement.is_available === false) {
|
||||||
const username = (await torn.user.profile(slot.user.id)).name;
|
const username = (await torn.user.profile(slot.user.id)).name;
|
||||||
const itemname = (await torn.cache.item(slot.item_requirement.id)).name;
|
const itemname = (await torn.item(slot.item_requirement.id)).name;
|
||||||
console.debug(`noItemOC: Found crime with unavailable item: ${crime.name}: ${slot.user.id}`);
|
console.debug(`noItemOC: Found crime with unavailable item: ${crime.name}: ${slot.user.id}`);
|
||||||
message += `[${username}](https://www.torn.com/profiles.php?XID=${slot.user.id}) needs [${itemname}](https://www.torn.com/page.php?sid=ItemMarket#/market/view=search&itemID=${slot.item_requirement.id}) for [${crime.name}](https://www.torn.com/factions.php?step=your&type=1#/tab=crimes&crimeId=${crime.id})\n`;
|
message += `[${username}](https://www.torn.com/profiles.php?XID=${slot.user.id}) needs [${itemname}](https://www.torn.com/page.php?sid=ItemMarket#/market/view=search&itemID=${slot.item_requirement.id}) for [${crime.name}](https://www.torn.com/factions.php?step=your&type=1#/tab=crimes&crimeId=${crime.id})\n`;
|
||||||
itemsneeded++;
|
itemsneeded++;
|
||||||
|
|||||||
61
tasks/trackOC.js
Normal file
61
tasks/trackOC.js
Normal file
@@ -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.");
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
module.exports = async (client, torn, config) => {
|
module.exports = async (client, torn, config) => {
|
||||||
console.debug("Task: Executing unavailableOC");
|
|
||||||
const { EmbedBuilder } = require('discord.js');
|
const { EmbedBuilder } = require('discord.js');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const channel = client.channels.resolve(config.channels.ocAlert);
|
const channel = client.channels.resolve(config.channels.ocAlert);
|
||||||
@@ -41,18 +41,33 @@ module.exports = async (client, torn, config) => {
|
|||||||
let embed = new EmbedBuilder()
|
let embed = new EmbedBuilder()
|
||||||
.setTitle('Crime Availability Check')
|
.setTitle('Crime Availability Check')
|
||||||
await torn.faction.crimes({ category: 'recruiting', offset: 0, sort: 'DESC' }).then(crimeList => {
|
await torn.faction.crimes({ category: 'recruiting', offset: 0, sort: 'DESC' }).then(crimeList => {
|
||||||
|
if (!crimeList) {
|
||||||
|
console.error("unavailableOC: API returned no crimes.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const data = { crimes: crimeList };
|
const data = { crimes: crimeList };
|
||||||
data.crimes.forEach(crime => {
|
data.crimes.forEach(crime => {
|
||||||
crimes.difficulty[crime.difficulty - 1].count++
|
if (crime.difficulty >= 1 && crime.difficulty <= factionMaxCrime) {
|
||||||
|
crimes.difficulty[crime.difficulty - 1].count++
|
||||||
|
}
|
||||||
});
|
});
|
||||||
let isSomethingZero = false;
|
let isSomethingZero = false;
|
||||||
crimes.difficulty.forEach(difficulty => {
|
crimes.difficulty.forEach(difficulty => {
|
||||||
console.debug(`unavailableOC: ${difficulty.name}: ${difficulty.count}`);
|
console.debug(`unavailableOC: ${difficulty.name}: ${difficulty.count}`);
|
||||||
if (difficulty.count === 0) {
|
if (difficulty.count === 0) {
|
||||||
isSomethingZero = true;
|
const level = parseInt(difficulty.name);
|
||||||
|
let shouldNotify = true;
|
||||||
|
if (config.crimeNotify && config.crimeNotify[level] === false) {
|
||||||
|
shouldNotify = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldNotify) {
|
||||||
|
isSomethingZero = true;
|
||||||
|
}
|
||||||
|
|
||||||
embed.addFields({
|
embed.addFields({
|
||||||
name: `Difficulty ${difficulty.name}`,
|
name: `Difficulty ${difficulty.name}`,
|
||||||
value: `Nobody can sign up for ${difficulty.name} crimes!`
|
value: `Nobody can sign up for ${difficulty.name} crimes!${shouldNotify ? '' : ' (muted)'}`
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
embed.addFields({
|
embed.addFields({
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
module.exports = async (client, torn, config) => {
|
module.exports = async (client, torn, config) => {
|
||||||
console.debug("Task: Executing unpaidOC");
|
|
||||||
const { EmbedBuilder } = require('discord.js');
|
const { EmbedBuilder } = require('discord.js');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const channel = client.channels.resolve(config.channels.ocAlert);
|
const channel = client.channels.resolve(config.channels.ocAlert);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const state = require('../state.json');
|
const state = require('../state.json');
|
||||||
let embeds = [];
|
let embeds = [];
|
||||||
const data = { crimes: await torn.faction.crimes({ category: 'successful', from: now.getTime() / 1000 - 7 * 24 * 60 * 60, sort: 'DESC' }) };
|
const crimesList = await torn.faction.crimes({ category: 'successful', from: now.getTime() / 1000 - 7 * 24 * 60 * 60, sort: 'DESC' });
|
||||||
|
if (!crimesList) {
|
||||||
|
console.error("unpaidOC: API returned no crimes.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = { crimes: crimesList };
|
||||||
for (const crime of data.crimes) {
|
for (const crime of data.crimes) {
|
||||||
if (!crime.rewards.payout) {
|
if (!crime.rewards.payout) {
|
||||||
console.debug(`unpaidOC: Found unpaid crime: ${crime.name}:${crime.id}`);
|
console.debug(`unpaidOC: Found unpaid crime: ${crime.name}:${crime.id}`);
|
||||||
const execDate = new Date(crime.executed_at * 1000);
|
const execDate = new Date(crime.executed_at * 1000);
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle(crime.name)
|
.setTitle(crime.name)
|
||||||
.setDescription(`Completed <t:${execDate.getTime() / 1000}:R>\nCash earned: $${crime.rewards.money}`)
|
.setDescription(`Completed <t:${execDate.getTime() / 1000}:R>\nCash earned: $${crime.rewards.money.toLocaleString()}\nSplit per person: $${Math.floor((crime.rewards.money * 0.9) / crime.slots.length).toLocaleString()}`)
|
||||||
.setURL(`https://www.torn.com/factions.php?step=your&type=7#/tab=crimes&crimeId=${crime.id}`);
|
.setURL(`https://www.torn.com/factions.php?step=your&type=7#/tab=crimes&crimeId=${crime.id}`);
|
||||||
if (crime.rewards.money === 0) {
|
if (crime.rewards.money === 0) {
|
||||||
const itemPromises = crime.rewards.items.map(item =>
|
const itemPromises = crime.rewards.items.map(item =>
|
||||||
torn.cache.item(item.id).then(itemData => ({
|
torn.item(item.id, true).then(itemData => ({
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
name: itemData.name,
|
name: itemData.name,
|
||||||
value: itemData.value.market_price
|
value: itemData.value.market_price
|
||||||
|
|||||||
289
torn.js
289
torn.js
@@ -10,7 +10,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
cache = require('./cache.json');
|
cache = require('./data/cache.json');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
cache = {
|
cache = {
|
||||||
users: {},
|
users: {},
|
||||||
@@ -19,29 +19,46 @@ try {
|
|||||||
items: {}
|
items: {}
|
||||||
};
|
};
|
||||||
try {
|
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) {
|
} catch (writeErr) {
|
||||||
console.error("Failed to write initial cache.json", writeErr);
|
console.error("Failed to write initial cache.json", writeErr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const TIME_12H = 12 * 60 * 60 * 1000;
|
const HOURS = 60 * 60 * 1000;
|
||||||
const TIME_7D = 7 * 24 * 60 * 60 * 1000;
|
const TTL = {
|
||||||
const TIME_30D = 30 * 24 * 60 * 60 * 1000;
|
USER: 12 * HOURS,
|
||||||
|
FACTION: 12 * HOURS,
|
||||||
|
COMPANY: 12 * HOURS,
|
||||||
|
ITEM: 7 * 24 * HOURS,
|
||||||
|
ITEM_LOOKUP: 30 * 24 * HOURS
|
||||||
|
};
|
||||||
|
|
||||||
// Helper to save cache
|
// Helper to save cache
|
||||||
function saveCache() {
|
function saveCache() {
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
console.error("Failed to save cache:", e);
|
console.error("Failed to save cache:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic Caching Helper
|
// Generic Caching Helper
|
||||||
async function getCached(collectionName, id, fetchFn, ttl) {
|
async function getCached(collectionName, id, fetchFn, ttl, force = false) {
|
||||||
const now = new Date().getTime();
|
const now = new Date().getTime();
|
||||||
|
|
||||||
|
// Ensure nested object exists
|
||||||
|
if (!cache[collectionName]) cache[collectionName] = {};
|
||||||
|
|
||||||
const item = cache[collectionName][id];
|
const item = cache[collectionName][id];
|
||||||
let lastUpdated = 0;
|
let lastUpdated = 0;
|
||||||
|
|
||||||
@@ -53,37 +70,23 @@ async function getCached(collectionName, id, fetchFn, ttl) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item && (now - lastUpdated < ttl)) {
|
if (!force && item && (now - lastUpdated < ttl)) {
|
||||||
console.debug(`Cache: Hit for ${collectionName} ${item.name || id}`);
|
console.debug(`Cache: Hit for ${collectionName} ${item.name || id}`);
|
||||||
return item;
|
return item;
|
||||||
} else {
|
} else {
|
||||||
console.debug(`Cache: Miss for ${collectionName} ${id || 'unknown'}`);
|
if (force) console.debug(`Cache: Force refresh for ${collectionName} ${id || 'unknown'}`);
|
||||||
|
else console.debug(`Cache: Miss for ${collectionName} ${id || 'unknown'}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// The fetchFn is expected to update the cache and return the data, or we can structure it differently.
|
|
||||||
// Based on the refactor code below, the fetchFn calls saveCache() and returns the data.
|
|
||||||
// But wait, the original logic for checking cache was inside the 'cache' object functions,
|
|
||||||
// calling the specific fetcher which updated the cache.
|
|
||||||
// In the refactored 'api.cache.user' below, I call 'api.user.basic(user)'.
|
|
||||||
// 'api.user.basic' updates the cache and returns data.
|
|
||||||
// So this helper just needs to return that result.
|
|
||||||
// BUT, I need to make sure I return the logical object.
|
|
||||||
|
|
||||||
const result = await fetchFn();
|
const result = await fetchFn();
|
||||||
console.debug(`Cache: Resolved ${collectionName} ${id}`);
|
console.debug(`Cache: Resolved ${collectionName} ${result.name || result.title || id}`);
|
||||||
|
|
||||||
// If the fetchFn updated the cache, we can return the cached item to be consistent
|
// Update cache with full result
|
||||||
// or just the result. The original returned the cached item in the cache wrapper.
|
cache[collectionName][id] = {
|
||||||
// Let's return the result from fetchFn which is usually the data.
|
...result,
|
||||||
// However, the original cache wrappers returned `cache.users[user]`.
|
updated: new Date().toISOString()
|
||||||
// Let's see if there is a difference.
|
};
|
||||||
// `api.user.basic` returns `data`. `cache.users[user]` is a subset of `data`?
|
saveCache();
|
||||||
// Original:
|
|
||||||
// `cache.users[user] = { name, player_id, level, ... }`
|
|
||||||
// `return(data)` (full api response)
|
|
||||||
// But `module.exports.cache.user` returned `cache.users[user]`.
|
|
||||||
// So the CACHE wrapper returned the CACHED OBJECT (subset), while the FETCH function returned the FULL API response.
|
|
||||||
// This is a subtle difference.
|
|
||||||
// If I want to maintain compatibility, `getCached` should return the cached item from `cache` after fetching.
|
|
||||||
|
|
||||||
return cache[collectionName][id];
|
return cache[collectionName][id];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -97,15 +100,27 @@ async function getCached(collectionName, id, fetchFn, ttl) {
|
|||||||
async function fetchApi(path) {
|
async function fetchApi(path) {
|
||||||
const glue = path.includes('?') ? '&' : '?';
|
const glue = path.includes('?') ? '&' : '?';
|
||||||
const response = await fetch(`${path}${glue}key=${config.torn}`);
|
const response = await fetch(`${path}${glue}key=${config.torn}`);
|
||||||
return response.json();
|
const data = await response.json();
|
||||||
|
if (data.error) {
|
||||||
|
console.error(`Torn API Error on ${path}:`, JSON.stringify(data.error));
|
||||||
|
throw new Error(data.error.error || "Torn API Error");
|
||||||
|
}
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
|
self: {}, // Will be populated by readyCheck
|
||||||
|
|
||||||
readyCheck: async (key) => {
|
readyCheck: async (key) => {
|
||||||
const url = `https://api.torn.com/user/?selections=basic&key=${key}`;
|
try {
|
||||||
const response = await fetch(url);
|
// Fetch own 'basic' data using V2 (which returns profile object)
|
||||||
const data = await response.json();
|
// By passing null/undefined as user, api.user.basic defaults to 'self' cache key
|
||||||
console.log(`Torn: Connected as ${data.name} [${data.player_id}]`);
|
const data = await api.user.basic(null, true);
|
||||||
|
api.self = data;
|
||||||
|
console.log(`Torn: Connected as ${data.name} [${data.player_id}]`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Torn: Critical error during startup check", e);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
test: async () => {
|
test: async () => {
|
||||||
@@ -120,47 +135,22 @@ const api = {
|
|||||||
return response.json();
|
return response.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
cache: {
|
|
||||||
async user(user) {
|
|
||||||
return getCached('users', user, async () => await api.user.basic(user), TIME_12H);
|
|
||||||
},
|
|
||||||
async faction(faction) {
|
|
||||||
return getCached('factions', faction, async () => await api.faction.basic(faction), TIME_12H);
|
|
||||||
},
|
|
||||||
async company(company) {
|
|
||||||
return getCached('companies', company, async () => await api.company(company), TIME_12H);
|
|
||||||
},
|
|
||||||
async item(item) {
|
|
||||||
return getCached('items', item, async () => await api.item(item), TIME_7D);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
user: {
|
user: {
|
||||||
async basic(user) {
|
async basic(user, force = false) {
|
||||||
const data = await fetchApi(`https://api.torn.com/user/${user}?selections=basic`);
|
const endpoint = user ? `https://api.torn.com/v2/user/${user}/basic` : `https://api.torn.com/v2/user/basic`;
|
||||||
const now = new Date();
|
return getCached('users', user || 'self', async () => {
|
||||||
cache.users[user] = {
|
const data = await fetchApi(endpoint);
|
||||||
name: data.name,
|
if (data.profile) data.profile.player_id = data.profile.id; // Shim for V1 compatibility
|
||||||
player_id: data.player_id,
|
return data.profile; // V2 wraps in 'profile'
|
||||||
level: data.level,
|
}, TTL.USER, force);
|
||||||
gender: data.gender,
|
|
||||||
updated: now.toISOString()
|
|
||||||
};
|
|
||||||
saveCache();
|
|
||||||
return data;
|
|
||||||
},
|
},
|
||||||
async profile(user) {
|
async profile(user, force = false) {
|
||||||
const data = await fetchApi(`https://api.torn.com/user/${user}?selections=profile`);
|
const endpoint = user ? `https://api.torn.com/v2/user/${user}/profile` : `https://api.torn.com/v2/user/profile`;
|
||||||
const now = new Date();
|
return getCached('users', user || 'self', async () => {
|
||||||
cache.users[user] = {
|
const data = await fetchApi(endpoint);
|
||||||
name: data.name,
|
if (data.profile) data.profile.player_id = data.profile.id; // Shim for V1 compatibility
|
||||||
player_id: data.player_id,
|
return data.profile; // V2 wraps in 'profile'
|
||||||
level: data.level,
|
}, TTL.USER, force);
|
||||||
gender: data.gender,
|
|
||||||
updated: now.toISOString()
|
|
||||||
};
|
|
||||||
saveCache();
|
|
||||||
return data;
|
|
||||||
},
|
},
|
||||||
async stats(user, category, statName) {
|
async stats(user, category, statName) {
|
||||||
let url = `https://api.torn.com/v2/user`;
|
let url = `https://api.torn.com/v2/user`;
|
||||||
@@ -169,28 +159,62 @@ const api = {
|
|||||||
if (statName) { url += `&stat=${statName}`; }
|
if (statName) { url += `&stat=${statName}`; }
|
||||||
return fetchApi(url);
|
return fetchApi(url);
|
||||||
},
|
},
|
||||||
// Added lookup to maintain feature parity if it was ever needed, though not in original user object
|
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: {
|
faction: {
|
||||||
async basic(faction) {
|
async basic(faction, force = false) {
|
||||||
const endpoint = faction ? `https://api.torn.com/v2/faction/${faction}/basic` : `https://api.torn.com/v2/faction/basic`;
|
// If faction is null, we can't key by ID easily until we fetch.
|
||||||
const response = await fetchApi(endpoint);
|
// For now, let's assume if faction is provided we use it as key.
|
||||||
// v2 return structure: { basic: { ... } }
|
// If not provided, we might be fetching our own faction.
|
||||||
const data = response.basic;
|
// We can key it by "own" or similar if needed, but let's see.
|
||||||
|
// If faction is missing, we fetch own faction, resulting data has ID.
|
||||||
|
|
||||||
const now = new Date();
|
// Special handling: if faction is undefined, we can't check cache by ID easily without knowing ID.
|
||||||
// Store by ID. If faction is null (own faction), we rely on data.id
|
// However, we can use a special key like 'own' or skip cache check pre-fetch?
|
||||||
cache.factions[data.id] = {
|
// Better: If no ID provided, we just fetch to be safe, OR we assume config.factionID if we had it.
|
||||||
name: data.name,
|
// Let's implement transparent fetching without ID -> fetch -> cache by ID.
|
||||||
leader_id: data.leader_id,
|
|
||||||
capacity: data.capacity,
|
if (!faction) {
|
||||||
rank: data.rank,
|
const endpoint = `https://api.torn.com/v2/faction/basic`;
|
||||||
best_chain: data.best_chain,
|
const response = await fetchApi(endpoint);
|
||||||
updated: now.toISOString()
|
const data = response.basic;
|
||||||
};
|
// We can update cache here manually
|
||||||
saveCache();
|
cache.factions[data.id] = { ...data, updated: new Date().toISOString() };
|
||||||
return data;
|
saveCache();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getCached('factions', faction, async () => {
|
||||||
|
const endpoint = `https://api.torn.com/v2/faction/${faction}/basic`;
|
||||||
|
const response = await fetchApi(endpoint);
|
||||||
|
return response.basic;
|
||||||
|
}, TTL.FACTION, force);
|
||||||
},
|
},
|
||||||
async members(faction) {
|
async members(faction) {
|
||||||
const endpoint = faction ? `https://api.torn.com/v2/faction/${faction}/members?striptags=true` : `https://api.torn.com/v2/faction/members?striptags=true`;
|
const endpoint = faction ? `https://api.torn.com/v2/faction/${faction}/members?striptags=true` : `https://api.torn.com/v2/faction/members?striptags=true`;
|
||||||
@@ -199,12 +223,12 @@ const api = {
|
|||||||
},
|
},
|
||||||
async crimes(options = {}) {
|
async crimes(options = {}) {
|
||||||
let params = new URLSearchParams();
|
let params = new URLSearchParams();
|
||||||
let category = '';
|
|
||||||
|
|
||||||
if (typeof options === 'string') {
|
if (typeof options === 'string') {
|
||||||
category = options;
|
params.append('cat', options);
|
||||||
} else {
|
} else {
|
||||||
if (options.category) category = options.category;
|
if (options.category) params.append('cat', options.category);
|
||||||
if (options.from) params.append('from', options.from);
|
if (options.from) params.append('from', options.from);
|
||||||
if (options.to) params.append('to', options.to);
|
if (options.to) params.append('to', options.to);
|
||||||
if (options.limit) params.append('limit', options.limit);
|
if (options.limit) params.append('limit', options.limit);
|
||||||
@@ -213,7 +237,7 @@ const api = {
|
|||||||
if (options.initiator) params.append('initiator', options.initiator);
|
if (options.initiator) params.append('initiator', options.initiator);
|
||||||
}
|
}
|
||||||
|
|
||||||
const endpoint = category ? `https://api.torn.com/v2/faction/crimes/${category}` : `https://api.torn.com/v2/faction/crimes`;
|
const endpoint = `https://api.torn.com/v2/faction/crimes`;
|
||||||
const queryString = params.toString() ? `?${params.toString()}` : '';
|
const queryString = params.toString() ? `?${params.toString()}` : '';
|
||||||
|
|
||||||
const data = await fetchApi(`${endpoint}${queryString}`);
|
const data = await fetchApi(`${endpoint}${queryString}`);
|
||||||
@@ -245,37 +269,35 @@ const api = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// company was a top-level function in export, but also used as property
|
company: async (company, force = false) => {
|
||||||
// Original: module.exports.company = async ...
|
if (!company) {
|
||||||
// So api.company should be a function
|
const endpoint = `https://api.torn.com/company/?selections=profile`;
|
||||||
company: async (company) => {
|
const data = await fetchApi(endpoint);
|
||||||
const endpoint = company ? `https://api.torn.com/company/${company}?selections=profile` : `https://api.torn.com/company/?selections=profile`;
|
// ID is data.company.ID
|
||||||
const data = await fetchApi(endpoint);
|
// Torn API v1/v2 difference? URL says /company/? so likely v1 standard structure
|
||||||
const now = new Date();
|
// Let's assume data.company exists.
|
||||||
// company ID is data.company.ID
|
if (data.company) {
|
||||||
cache.companies[data.company.ID] = {
|
cache.companies[data.company.ID] = { ...data.company, updated: new Date().toISOString() };
|
||||||
name: data.company.name,
|
saveCache();
|
||||||
id: data.company.ID,
|
return data.company;
|
||||||
company_type: data.company.company_type,
|
}
|
||||||
director_id: data.company.director,
|
return data;
|
||||||
rating: data.company.rating,
|
}
|
||||||
updated: now.toISOString()
|
|
||||||
};
|
return getCached('companies', company, async () => {
|
||||||
saveCache();
|
const endpoint = `https://api.torn.com/company/${company}?selections=profile`;
|
||||||
return data.company;
|
const data = await fetchApi(endpoint);
|
||||||
|
return data.company;
|
||||||
|
}, TTL.COMPANY, force);
|
||||||
},
|
},
|
||||||
|
|
||||||
// item was a function with a .lookup property
|
// item was a function with a .lookup property
|
||||||
item: Object.assign(
|
item: Object.assign(
|
||||||
async (item) => {
|
async (item, force = false) => {
|
||||||
const data = await fetchApi(`https://api.torn.com/v2/torn/${item}/items?sort=ASC`);
|
return getCached('items', item, async () => {
|
||||||
const now = new Date();
|
const data = await fetchApi(`https://api.torn.com/v2/torn/${item}/items?sort=ASC`);
|
||||||
cache.items[item] = data.items[0]; // Assuming item is ID
|
return data.items[0];
|
||||||
if (cache.items[item]) {
|
}, TTL.ITEM, force);
|
||||||
cache.items[item].updated = now.toISOString();
|
|
||||||
}
|
|
||||||
saveCache();
|
|
||||||
return data.items[0];
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
lookup: async (itemName) => {
|
lookup: async (itemName) => {
|
||||||
@@ -288,7 +310,7 @@ const api = {
|
|||||||
let last = 0;
|
let last = 0;
|
||||||
try { last = new Date(cache.items[itemId].updated).getTime(); } catch (e) { }
|
try { last = new Date(cache.items[itemId].updated).getTime(); } catch (e) { }
|
||||||
|
|
||||||
if (now - last < TIME_30D) {
|
if (now - last < TTL.ITEM_LOOKUP) {
|
||||||
console.debug(`Cache: Hit for item ${cache.items[itemId].name}`);
|
console.debug(`Cache: Hit for item ${cache.items[itemId].name}`);
|
||||||
return cache.items[itemId];
|
return cache.items[itemId];
|
||||||
}
|
}
|
||||||
@@ -317,20 +339,7 @@ const api = {
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|
||||||
self: {
|
|
||||||
async id() {
|
|
||||||
if (!config.tornid) {
|
|
||||||
const url = `https://api.torn.com/user/?selections=basic&key=${config.torn}`;
|
|
||||||
const response = await fetch(url);
|
|
||||||
const data = await response.json();
|
|
||||||
config.tornid = data.player_id;
|
|
||||||
console.log(`Torn: Retrieved default ID as "${data.player_id}"`);
|
|
||||||
return data.player_id;
|
|
||||||
} else {
|
|
||||||
return config.tornid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = api;
|
module.exports = api;
|
||||||
214
utils/UpgradeRenderer.js
Normal file
214
utils/UpgradeRenderer.js
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
const { createCanvas } = require('canvas');
|
||||||
|
const config = require('../config.json');
|
||||||
|
|
||||||
|
class UpgradeRenderer {
|
||||||
|
constructor() {
|
||||||
|
this.padding = 24;
|
||||||
|
this.maxWidth = 1100;
|
||||||
|
this.fontFamily = 'Sans';
|
||||||
|
this.baseColors = {
|
||||||
|
core: config.upgradeColors.core,
|
||||||
|
peace: config.upgradeColors.peace,
|
||||||
|
peaceDim: config.upgradeColors.peaceDim,
|
||||||
|
war: config.upgradeColors.war,
|
||||||
|
warDim: config.upgradeColors.warDim,
|
||||||
|
background: config.upgradeColors.background
|
||||||
|
};
|
||||||
|
this.fontSizes = { 0: 26, 1: 22, 2: 18 };
|
||||||
|
this.lineHeightFactor = 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the upgrades data to a PNG buffer.
|
||||||
|
* @param {Object} data The data returned from torn.faction.upgrades()
|
||||||
|
* @returns {Buffer} The PNG image buffer
|
||||||
|
*/
|
||||||
|
render(data) {
|
||||||
|
const lines = this.buildLines(data);
|
||||||
|
const state = (data.state || '').toLowerCase();
|
||||||
|
const dimMode = (state === 'peace' || state === 'war') ? 'opacity' : 'desaturate';
|
||||||
|
const inactiveGroup = state === 'peace' ? 'war' : (state === 'war' ? 'peace' : null);
|
||||||
|
|
||||||
|
// Measurement Canvas
|
||||||
|
const measureCanvas = createCanvas(10, 10);
|
||||||
|
const measureCtx = measureCanvas.getContext('2d');
|
||||||
|
|
||||||
|
const visualLines = this.measureAndWrapLines(measureCtx, lines);
|
||||||
|
const { width, height } = this.calculateCanvasDimensions(visualLines);
|
||||||
|
|
||||||
|
// Final Canvas
|
||||||
|
const canvas = createCanvas(width, height);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
this.drawBackground(ctx, width, height);
|
||||||
|
this.drawText(ctx, visualLines, width, dimMode, inactiveGroup);
|
||||||
|
|
||||||
|
return canvas.toBuffer('image/png');
|
||||||
|
}
|
||||||
|
|
||||||
|
buildLines(data) {
|
||||||
|
const lines = []; // { text: string, group: 'core'|'peace'|'war'|null }
|
||||||
|
|
||||||
|
// Core
|
||||||
|
lines.push({ text: 'Core Upgrades', group: 'core' });
|
||||||
|
const armoryNames = [];
|
||||||
|
if (data.upgrades.core.upgrades) {
|
||||||
|
for (const upgrade of data.upgrades.core.upgrades) {
|
||||||
|
if (upgrade.name && String(upgrade.name).toLowerCase().includes('armory')) {
|
||||||
|
armoryNames.push(upgrade.name.replace(/\s+armory$/i, ''));
|
||||||
|
} else {
|
||||||
|
lines.push({ text: ` ${upgrade.name} - ${upgrade.ability}`, group: 'core' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (armoryNames.length) {
|
||||||
|
lines.push({ text: ` Armory: ${armoryNames.join(', ')}`, group: 'core' });
|
||||||
|
}
|
||||||
|
lines.push({ text: '', group: null });
|
||||||
|
|
||||||
|
// Peace
|
||||||
|
lines.push({ text: 'Peace Upgrades', group: 'peace' });
|
||||||
|
if (data.upgrades.peace) {
|
||||||
|
for (const branch of data.upgrades.peace) {
|
||||||
|
lines.push({ text: ` ${branch.name}`, group: 'peace' });
|
||||||
|
for (const upgrade of branch.upgrades) {
|
||||||
|
lines.push({ text: ` ${upgrade.name} - ${upgrade.ability}`, group: 'peace' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push({ text: '', group: null });
|
||||||
|
|
||||||
|
// War
|
||||||
|
lines.push({ text: 'War Upgrades', group: 'war' });
|
||||||
|
if (data.upgrades.war) {
|
||||||
|
for (const branch of data.upgrades.war) {
|
||||||
|
lines.push({ text: ` ${branch.name}`, group: 'war' });
|
||||||
|
for (const upgrade of branch.upgrades) {
|
||||||
|
lines.push({ text: ` ${upgrade.name} - ${upgrade.ability}`, group: 'war' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
measureAndWrapLines(ctx, lines) {
|
||||||
|
const visualLines = [];
|
||||||
|
const textMaxWidth = this.maxWidth - this.padding * 2;
|
||||||
|
|
||||||
|
for (const ln of lines) {
|
||||||
|
if (!ln.text) {
|
||||||
|
visualLines.push({
|
||||||
|
text: '',
|
||||||
|
group: null,
|
||||||
|
level: 0,
|
||||||
|
width: 0,
|
||||||
|
fontSize: this.fontSizes[0],
|
||||||
|
lineHeight: Math.round(this.fontSizes[0] * this.lineHeightFactor)
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leading = (ln.text.match(/^ */) || [''])[0].length;
|
||||||
|
let level = Math.min(2, Math.floor(leading / 2));
|
||||||
|
if (ln.group === 'core' && level === 1) level = 2;
|
||||||
|
|
||||||
|
const fsz = this.fontSizes[level];
|
||||||
|
ctx.font = `${fsz}px ${this.fontFamily}`;
|
||||||
|
|
||||||
|
const rawText = ln.text.trim();
|
||||||
|
const wrapped = this.wrapLine(ctx, rawText, textMaxWidth);
|
||||||
|
|
||||||
|
for (const wln of wrapped) {
|
||||||
|
const w = Math.ceil(ctx.measureText(wln).width);
|
||||||
|
visualLines.push({
|
||||||
|
text: wln,
|
||||||
|
group: ln.group,
|
||||||
|
level,
|
||||||
|
width: w,
|
||||||
|
fontSize: fsz,
|
||||||
|
lineHeight: Math.round(fsz * this.lineHeightFactor)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return visualLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapLine(ctx, text, maxW) {
|
||||||
|
const words = text.split(' ');
|
||||||
|
const wrapped = [];
|
||||||
|
let line = '';
|
||||||
|
for (const word of words) {
|
||||||
|
const test = line ? `${line} ${word}` : word;
|
||||||
|
const w = ctx.measureText(test).width;
|
||||||
|
if (w > maxW && line) {
|
||||||
|
wrapped.push(line);
|
||||||
|
line = word;
|
||||||
|
} else {
|
||||||
|
line = test;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (line) wrapped.push(line);
|
||||||
|
return wrapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateCanvasDimensions(visualLines) {
|
||||||
|
let maxW = 0;
|
||||||
|
let totalH = 0;
|
||||||
|
for (const line of visualLines) {
|
||||||
|
maxW = Math.max(maxW, line.width);
|
||||||
|
totalH += line.lineHeight;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
width: Math.min(this.maxWidth, maxW + this.padding * 2),
|
||||||
|
height: this.padding * 2 + totalH
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
drawBackground(ctx, width, height) {
|
||||||
|
const cornerRadius = 24;
|
||||||
|
ctx.fillStyle = this.baseColors.background;
|
||||||
|
|
||||||
|
const r = Math.max(0, Math.min(cornerRadius, Math.min(width / 2, height / 2)));
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(r, 0);
|
||||||
|
ctx.lineTo(width - r, 0);
|
||||||
|
ctx.quadraticCurveTo(width, 0, width, r);
|
||||||
|
ctx.lineTo(width, height - r);
|
||||||
|
ctx.quadraticCurveTo(width, height, width - r, height);
|
||||||
|
ctx.lineTo(r, height);
|
||||||
|
ctx.quadraticCurveTo(0, height, 0, height - r);
|
||||||
|
ctx.lineTo(0, r);
|
||||||
|
ctx.quadraticCurveTo(0, 0, r, 0);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawText(ctx, visualLines, width, dimMode, inactiveGroup) {
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
let y = this.padding;
|
||||||
|
|
||||||
|
for (const vln of visualLines) {
|
||||||
|
ctx.font = `${vln.fontSize}px ${this.fontFamily}`;
|
||||||
|
ctx.fillStyle = this.getColorForGroup(vln.group, dimMode, inactiveGroup);
|
||||||
|
|
||||||
|
const x = Math.round((width - vln.width) / 2);
|
||||||
|
ctx.fillText(vln.text, x, y);
|
||||||
|
y += vln.lineHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getColorForGroup(group, dimMode, inactiveGroup) {
|
||||||
|
if (!group) return '#ffffff';
|
||||||
|
if (group === 'core') return this.baseColors.core;
|
||||||
|
|
||||||
|
if (dimMode === 'opacity') {
|
||||||
|
if (group === inactiveGroup) return group === 'peace' ? this.baseColors.peaceDim : this.baseColors.warDim;
|
||||||
|
return group === 'peace' ? this.baseColors.peace : this.baseColors.war;
|
||||||
|
} else {
|
||||||
|
return group === 'peace' ? this.baseColors.peaceDim : this.baseColors.warDim;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new UpgradeRenderer();
|
||||||
183
utils/ocLogic.js
Normal file
183
utils/ocLogic.js
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
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<Object>} 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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>} 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,
|
||||||
|
fetchAndProcessHistory
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user