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.
let 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 } ` } ,
] ,
} ) ;
let 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.
let 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.
let 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 ) ;
2025-04-27 02:22:13 +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-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
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 ) ) {
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 ( ) => {
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-02 16:45:36 +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-02 16:45:36 +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 ;
} ;
} ;