ClientX/_opt/condimentX.js

416 lines
20 KiB
JavaScript
Raw Permalink Normal View History

2025-04-25 21:27:00 -04:00
/**
* condimentX module - Viral infection role simulation
*
* Periodically designates an index case and randomly "infects" other members with a role.
* Logs detailed debug info, and can optionally use OpenAI to respond to users.
*
* Cycle Overview:
* 1. On client ready, the first incident cycle is scheduled after `firstCycleInterval` ms.
* 2. For each cycle:
* a. Gather current index cases (members with `indexRole`).
* b. Determine potential victims via `whitelistRoles` (fallback to `graylistRoles` if none).
* c. If no index cases and victims exist:
* - With probability `1 / incidenceDenominator`, choose a random victim and assign `indexRole`.
* d. Else if index cases exist:
* - With probability `1 / cessationDenominator`, remove `indexRole` from all index cases.
* e. Else (no capacity to start or end incidents): skip assignment/removal.
* f. Schedule the next cycle after `cycleInterval ± cycleIntervalRange` ms.
*
* Infection Spread:
* - On each messageCreate event, collects recent messages within `proximityWindow`.
* - Computes infection chance as `(infectedMessages / totalMessages) * 100`, capped at `probabilityLimit`.
* - If member has `antiViralRole`, reduces chance by `antiViralEffectiveness%`.
* - On success, assigns `viralRole` to the message author and removes `antiViralRole`.
*
* Event Hooks:
* - guildMemberUpdate: logs role additions/removals and clears viral roles when indexRole is removed.
* - messageCreate: handles proximity-based infection and optional OpenAI triggers.
*
* Configuration is expected under client.config.condimentX, including:
* dryRun, guildID, debugChannel, blacklistUsers, blacklistRoles,
* graylistRoles, whitelistRoles, indexRoleID, viralRoleID,
* antiIndexRoleID, antiViralRoleID, cycle intervals, probabilities,
* messageHistoryLimit, OpenAI settings, etc.
*/
export const init = async (client, config) => {
// Destructure all module settings
const {
dryRun,
guildID,
debugChannel,
blacklistUsers,
blacklistRoles,
graylistRoles,
whitelistRoles,
indexRoleID,
viralRoleID,
antiIndexRoleID,
antiViralRoleID,
firstCycleInterval,
cycleInterval,
cycleIntervalRange,
incidenceDenominator,
cessationDenominator,
probabilityLimit,
antiViralEffectiveness,
proximityWindow,
messageHistoryLimit,
ephemeralDelay,
openAI,
openAITriggerOnlyDuringIncident,
openAIResponseDenominator,
openAIInstructionsFile,
openAITriggers,
openAIWebhookID,
openAIWebhookToken,
openAIToken
} = config.condimentX;
// Import required modules
const { WebhookClient, Collection } = await import('discord.js');
const fs = await import('fs');
const OpenAI = (await import('openai')).default;
// Internal state variables
let incidentCounter = 0;
let guild, indexRole, viralRole, antiIndexRole, antiViralRole;
let openai, openAIWebhook, openAIWebhookClient;
// Used as a prefix before any line that runs within a loop.
const bullet = '>';
// === OpenAI Interaction ===
// Chat completion via OpenAI with provided instructions.
async function ai(prompt = '') {
try {
debug(`**AI Prompt**: ${prompt}`);
// Read instructions.
2025-05-08 01:52:12 +00:00
const openAIInstructions = fs.readFileSync(openAIInstructionsFile, 'utf8');
2025-04-25 21:27:00 -04:00
const unmention = /<@(\w+)>/g;
const completion = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
2025-05-08 01:52:12 +00:00
{ role: 'user', content: `${prompt.replace(unmention, '$1')}` },
{ role: 'system', content: `${openAIInstructions}` }
]
2025-04-25 21:27:00 -04:00
});
2025-05-08 01:52:12 +00:00
const chunk = completion.choices[0]?.message?.content;
if (chunk !== '') {
2025-04-25 21:27:00 -04:00
for (const line of chunk.split(/\n\s*\n/).filter(Boolean)) {
debug(`${bullet} ${line}`);
openAIWebhookClient.send(line);
}
}
} catch (error) {
anomaly(error);
}
}
// === Logging Helpers ===
// Send debug and error logs to the configured debugChannel and console.
async function debug(string, ephemeral = false) {
string += ephemeral ? ' :x:' : '';
try {
const channel = client.channels.cache.get(debugChannel);
const message = await channel.send(string);
if (ephemeral) {
setTimeout(() => message.delete().catch(error => client.logger.error(error)), ephemeralDelay);
}
} catch (error) {
client.logger.error(`CondimentX debug error: ${error.message}`);
} finally {
client.logger.debug(`CondimentX: ${string}`);
}
}
// === Error Handling Helpers ===
// Logs anomalies both to debug channel and error logger.
async function anomaly(tag, error) {
if (typeof error === 'object') {
if (error.message) {
debug(`**Anomaly**: *${tag}*\n\`\`\`\n${error.message}\n\`\`\``);
}
if (error.stack) {
debug(`**Stack Trace**\n\`\`\`\n${error.stack}\n\`\`\``);
}
} else {
debug(`**Anomaly**: *${tag}*\n\`\`\`\n${error}\n\`\`\``);
}
client.logger.error(`CondimentX anomaly ${tag}: ${error}`);
}
// === Message Fetching Helpers ===
// Retrieve recent messages from every text channel since a given timestamp.
2025-05-08 01:52:12 +00:00
async function _fetchRecentMessages(since) {
2025-04-25 21:27:00 -04:00
const allMessages = new Collection();
// Get all text channels in the guild
const textChannels = guild.channels.cache.filter(
channel => channel.type === 0 && channel.viewable
);
// For each channel, fetch recent messages
for (const channel of textChannels.values()) {
try {
2025-05-08 01:52:12 +00:00
const messages = await channel.messages.fetch({
2025-04-25 21:27:00 -04:00
limit: messageHistoryLimit,
2025-05-08 01:52:12 +00:00
after: since
2025-04-25 21:27:00 -04:00
});
// Add these messages to our collection
for (const [id, message] of messages) {
allMessages.set(id, message);
}
} catch (error) {
// Skip channels where we can't fetch messages
client.logger.warn(`Couldn't fetch messages from channel ${channel.name}: ${error.message}`);
}
}
return allMessages;
}
// === Incident Cycle Logic ===
// Periodically runs to potentially assign index roles and spread the infection.
// Schedules the next cycle based on cycleInterval and cycleIntervalRange.
async function cycleIncidents() {
try {
guild = client.guilds.cache.get(guildID);
debug(`**Incident Cycle #${incidentCounter++}**`);
// Rebuild the list of current index cases, if any.
2025-05-08 01:52:12 +00:00
const indexesList = guild.members.cache.filter(member => member.roles.cache.has(indexRole.id));
2025-04-25 21:27:00 -04:00
debug(`${bullet} Index Cases: **${indexesList.size}**`);
// Build the victimsList using whitelisted roles.
let victimsList = guild.members.cache.filter(member => {
const hasBlacklistedUser = blacklistUsers.includes(member.id);
const hasBlacklistedRole = blacklistRoles.some(role => member.roles.cache.has(role));
const hasWhitelistedRole = whitelistRoles.some(role => member.roles.cache.has(role));
return !hasBlacklistedUser && !hasBlacklistedRole && hasWhitelistedRole;
});
// If the victimsList is empty using whitelisted roles, use graylisted roles instead.
if (victimsList.size === 0) {
victimsList = guild.members.cache.filter(member => {
const hasBlacklistedUser = blacklistUsers.includes(member.id);
const hasBlacklistedRole = blacklistRoles.some(role => member.roles.cache.has(role));
const hasGraylistedRole = graylistRoles.some(role => member.roles.cache.has(role));
return !hasBlacklistedUser && !hasBlacklistedRole && hasGraylistedRole;
});
debug(`${bullet} Potential Victims: **${victimsList.size}** (**graylist**)`);
} else {
debug(`${bullet} Potential Victims: **${victimsList.size}** (**whitelist**)`);
}
// Conditions for potentially starting an incident.
2025-05-08 01:52:12 +00:00
if (indexesList.size === 0 && victimsList.size > 0) {
2025-04-25 21:27:00 -04:00
if ((Math.floor(Math.random() * incidenceDenominator) + 1) === 1) {
debug(`${bullet} Incidence Check: **Success**`);
const newIndex = victimsList.random();
debug(`${bullet} New index case: ${newIndex.displayName}`);
if (dryRun === false) {
await newIndex.roles.add(indexRole).catch(error => client.logger.error(error));
} else {
debug(`${bullet} dryRun = **${dryRun}**, *role change skipped*`);
}
} else {
debug(`${bullet} Incidence Check: **Failure**`);
}
// Otherwise, potentially end an incident.
} else if (indexesList.size > 0) {
if ((Math.floor(Math.random() * cessationDenominator) + 1) === 1) {
debug(`${bullet} Cessation Check: **Success**`);
for (const oldIndex of indexesList.values()) {
debug(`${bullet} Old index case: ${oldIndex.user.tag}`);
if (dryRun === false) {
await oldIndex.roles.remove(indexRole).catch(error => client.logger.error(error));
} else {
debug(`${bullet} dryRun = **${dryRun}**, *role change skipped*`);
}
}
} else {
debug(`${bullet} Cessation Check: **Failure**`);
}
// Edge cases.
} else {
debug(`${bullet} Insufficient population, aborting.`);
}
// Prepare the next cycle.
2025-05-08 01:52:12 +00:00
const interval = cycleInterval + Math.floor(Math.random() * (2 * cycleIntervalRange + 1)) - cycleIntervalRange;
2025-04-25 21:27:00 -04:00
setTimeout(cycleIncidents, interval);
debug(`${bullet} Cycle #${incidentCounter} **<t:${Math.floor((Date.now() + interval) / 1000)}:R>** at **<t:${Math.floor((Date.now() + interval) / 1000)}:t>**`);
} catch (error) {
anomaly('cycleIncidents', error);
}
}
// Deferred initialization: run after Discord client is ready
const guildMemberUpdateHandler = async (reference, member) => {
try {
if (member.guild.id !== guildID) return;
guild = client.guilds.cache.get(guildID);
if (!reference.roles.cache.has(indexRole.id) && member.roles.cache.has(indexRole.id)) {
debug(`***${indexRole.name} added to ${member.user.displayName}***.`);
if (openAI === true && (Math.floor(Math.random() * openAIResponseDenominator) + 1) === 1) {
ai(`${member.user.displayName} has received the ${indexRole.name} role.`);
}
}
if (reference.roles.cache.has(indexRole.id) && !member.roles.cache.has(indexRole.id)) {
debug(`***${indexRole.name} removed from ${member.user.displayName}***.`);
if (openAI === true && (Math.floor(Math.random() * openAIResponseDenominator) + 1) === 1) {
ai(`${member.user.displayName} has lost the ${indexRole.name} role.`);
}
guild.members.cache.forEach(async infected => {
if (infected.roles.cache.has(viralRole.id)) {
debug(`${bullet} Removing ${viralRole.name} from ${infected.user.displayName}.`);
if (!dryRun) await infected.roles.remove(viralRole).catch(e => client.logger.error(e));
else debug(`${bullet} dryRun = **${dryRun}** - *role change skipped*`);
}
});
}
if (!reference.roles.cache.has(viralRole.id) && member.roles.cache.has(viralRole.id)) {
debug(`***${viralRole.name} added to ${member.user.displayName}***.`);
if (openAI === true && (Math.floor(Math.random() * openAIResponseDenominator) + 1) === 1) {
ai(`${member.user.displayName} has received the ${viralRole.name} role.`);
}
}
} catch (error) {
anomaly('guildMemberUpdate', error);
}
};
const messageCreateHandler = async (message) => {
try {
if (!message.member || !message.author || !message.guild) return;
if (message.author.id === client.user.id) return;
if (message.guild.id !== guildID) return;
if (message.author.bot) return;
if (message.webhookId) return;
guild = client.guilds.cache.get(guildID);
2025-04-27 02:22:13 +00:00
2025-05-08 01:52:12 +00:00
// Someone mentioned us - respond if openAI is enabled, the message was in the webhook channel, and a trigger was used.
if (openAI === true && openAIWebhook.channel.id === message.channel.id && openAITriggers.some(word => message.content.replace(/[^\w\s]/gi, '').toLowerCase().includes(word.toLowerCase()))) {
// Also check if an active incident is required to respond.
if ((openAITriggerOnlyDuringIncident === true && guild.members.cache.filter(member => member.roles.cache.has(indexRole.id)).size > 0) || openAITriggerOnlyDuringIncident === false) {
// Finally, random roll to respond.
if ((Math.floor(Math.random() * openAIResponseDenominator) + 1) === 1) {
ai(`${message.member.displayName} said: ${message.cleanContent}`);
}
}
}
2025-04-27 02:22:13 +00:00
2025-04-25 21:27:00 -04:00
if (blacklistUsers.includes(message.author.id)) return;
if (message.member.roles.cache.some(r => blacklistRoles.includes(r.id))) return;
const timeThreshold = Date.now() - proximityWindow;
const recent = await message.channel.messages.fetch({ limit: messageHistoryLimit, before: message.id });
const prox = recent.filter(msg => msg.createdTimestamp >= timeThreshold && msg.guild.id === message.guildId);
let infections = 0;
// Count infected members in proximity messages
for (const msg of prox.values()) {
try {
const msgMember = msg.member;
if (msgMember) {
// Check if author has index or viral role
2025-05-08 01:52:12 +00:00
const isInfected = msgMember.roles.cache.has(indexRole.id) ||
2025-04-25 21:27:00 -04:00
msgMember.roles.cache.has(viralRole.id);
if (isInfected) infections++;
}
} catch (error) {
// Skip invalid proximity data
}
}
// Either condition results in 0% chance of infection, so abort now
if (infections === 0 || prox.size === 0) {
return;
}
// Base probability, capped at ${probabilityLimit} to control infection rate
let percentage = Math.min(infections / prox.size * 100, probabilityLimit);
// Reduce base probability by ${antiViralEffectiveness}% for those with ${antiViralRole}
2025-05-08 01:52:12 +00:00
if (message.member.roles.cache.has(antiViralRole.id) && Math.random() * 100 === antiViralEffectiveness) {
2025-04-25 21:27:00 -04:00
percentage = Math.round(percentage - (antiViralEffectiveness * (percentage / 100)));
}
// Infect the unlucky
if (infections > 0) {
const randomRoll = Math.round(Math.random() * 100);
if (randomRoll < percentage) {
const member = message.member;
debug(`${bullet} **${message.member.displayName}**: **infected**, proximity = **${infections}/${prox.size}**, immunity strength = **${randomRoll}**, infection strength = **${percentage}**`);
if (!dryRun) {
await member.roles.add(viralRole);
await member.roles.remove(antiViralRole);
} else {
debug(`${bullet} dryRun = **${dryRun}** - *role change skipped*`);
}
} else {
debug(`${bullet} **${message.member.displayName}**: **immunity**, proximity = **${infections}/${prox.size}**, immunity strength = **${randomRoll}**, infection strength = **${percentage}**`);
}
}
} catch (error) {
anomaly('messageCreate', error);
}
};
2025-05-08 01:52:12 +00:00
2025-04-25 21:27:00 -04:00
// Deferred setup on ready
const readyHandler = async () => {
2025-05-02 16:45:36 +00:00
client.logger.info('[module:condimentX] Initializing module');
2025-04-25 21:27:00 -04:00
if (openAI === true) {
2025-05-02 16:45:36 +00:00
openai = new OpenAI({ apiKey: openAIToken }); // credentials loaded
2025-04-25 21:27:00 -04:00
openAIWebhook = await client.fetchWebhook(openAIWebhookID, openAIWebhookToken).catch(error => {
2025-05-08 01:52:12 +00:00
client.logger.error(`[module:condimentX] Could not fetch webhook: ${error.message}`);
2025-04-25 21:27:00 -04:00
return null;
});
if (openAIWebhook) openAIWebhookClient = new WebhookClient({ id: openAIWebhookID, token: openAIWebhookToken });
}
try {
guild = client.guilds.cache.get(guildID);
if (!guild) {
2025-05-08 01:52:12 +00:00
client.logger.error(`[module:condimentX] Guild ${guildID} not found`);
2025-04-25 21:27:00 -04:00
return;
}
indexRole = await guild.roles.fetch(indexRoleID);
viralRole = await guild.roles.fetch(viralRoleID);
antiIndexRole = await guild.roles.fetch(antiIndexRoleID);
antiViralRole = await guild.roles.fetch(antiViralRoleID);
await guild.members.fetch();
} catch (error) {
anomaly('init', error);
}
debug(`${bullet} Initialized: <t:${Math.floor(Date.now()/1000)}>\n`);
debug(`${bullet} Index Role = \`${indexRole?.name}\` / \`${antiIndexRole?.name}\``);
debug(`${bullet} Viral Role = \`${viralRole?.name}\` / \`${antiViralRole?.name}\``);
debug(`${bullet} Incidence Probability = **1**:**${incidenceDenominator}**`);
debug(`${bullet} Cessation Probability = **1**:**${cessationDenominator}**`);
debug(`${bullet} Infection Probability Limit = **${probabilityLimit}**%`);
debug(`${bullet} AntiViral Effectiveness: **${antiViralEffectiveness}**%`);
debug(`${bullet} Dry Run: **${dryRun}**`);
debug(`${bullet} OpenAI: **${openAI}**`);
if (openAI === true) {
debug(`${bullet} OpenAI System Instructions: **${openAIInstructionsFile}**`);
debug(`${bullet} File Found: **${fs.existsSync(openAIInstructionsFile)}**`);
debug(`${bullet} OpenAI Webhook Display Name: **${openAIWebhook?.name || 'Not found'}**`);
}
// Schedule first incident cycle
setTimeout(cycleIncidents, firstCycleInterval);
debug(`${bullet} Initialization complete, first cycle **<t:${Math.floor((Date.now()+firstCycleInterval)/1000)}:R>** at **<t:${Math.floor((Date.now()+firstCycleInterval)/1000)}:t>**`);
client.on('guildMemberUpdate', guildMemberUpdateHandler);
client.on('messageCreate', messageCreateHandler);
};
client.once('ready', readyHandler);
// Return cleanup function
return () => {
client.removeListener('ready', readyHandler);
client.removeListener('guildMemberUpdate', guildMemberUpdateHandler);
client.removeListener('messageCreate', messageCreateHandler);
if (openAIWebhookClient) openAIWebhookClient = null;
};
};