/** * 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} **** at ****`); } 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: \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 **** at ****`); 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; }; };