416 lines
20 KiB
JavaScript
416 lines
20 KiB
JavaScript
/**
|
|
* 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.
|
|
const openAIInstructions = fs.readFileSync(openAIInstructionsFile, 'utf8');
|
|
const unmention = /<@(\w+)>/g;
|
|
const completion = await openai.chat.completions.create({
|
|
model: 'gpt-4o-mini',
|
|
messages: [
|
|
{ role: 'user', content: `${prompt.replace(unmention, '$1')}` },
|
|
{ role: 'system', content: `${openAIInstructions}` }
|
|
]
|
|
});
|
|
const chunk = completion.choices[0]?.message?.content;
|
|
if (chunk !== '') {
|
|
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.
|
|
async function _fetchRecentMessages(since) {
|
|
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 {
|
|
const messages = await channel.messages.fetch({
|
|
limit: messageHistoryLimit,
|
|
after: since
|
|
});
|
|
|
|
// 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.
|
|
const indexesList = guild.members.cache.filter(member => member.roles.cache.has(indexRole.id));
|
|
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.
|
|
if (indexesList.size === 0 && victimsList.size > 0) {
|
|
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.
|
|
const interval = cycleInterval + Math.floor(Math.random() * (2 * cycleIntervalRange + 1)) - cycleIntervalRange;
|
|
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);
|
|
|
|
// 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}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
const isInfected = msgMember.roles.cache.has(indexRole.id) ||
|
|
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}
|
|
if (message.member.roles.cache.has(antiViralRole.id) && Math.random() * 100 === antiViralEffectiveness) {
|
|
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);
|
|
}
|
|
};
|
|
|
|
// Deferred setup on ready
|
|
const readyHandler = async () => {
|
|
client.logger.info('[module:condimentX] Initializing module');
|
|
if (openAI === true) {
|
|
openai = new OpenAI({ apiKey: openAIToken }); // credentials loaded
|
|
openAIWebhook = await client.fetchWebhook(openAIWebhookID, openAIWebhookToken).catch(error => {
|
|
client.logger.error(`[module:condimentX] Could not fetch webhook: ${error.message}`);
|
|
return null;
|
|
});
|
|
if (openAIWebhook) openAIWebhookClient = new WebhookClient({ id: openAIWebhookID, token: openAIWebhookToken });
|
|
}
|
|
try {
|
|
guild = client.guilds.cache.get(guildID);
|
|
if (!guild) {
|
|
client.logger.error(`[module:condimentX] Guild ${guildID} not found`);
|
|
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;
|
|
};
|
|
};
|