Initial commit.

This commit is contained in:
jrmyr 2025-04-25 21:27:00 -04:00
commit af306313df
22 changed files with 5233 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.DS_Store
node_modules
.env
config.js
images/*
logs/*
pocketbase/*

412
_opt/condimentX.js Normal file
View File

@ -0,0 +1,412 @@
/**
* 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);
if (openAI === true && openAIWebhook && openAIWebhook.channelId === message.channel.id &&
openAITriggers.some(w => message.content.replace(/[^\\w\\s]/gi,'').toLowerCase().includes(w.toLowerCase())) &&
((openAITriggerOnlyDuringIncident === false) || (openAITriggerOnlyDuringIncident === true &&
guild.members.cache.some(m => m.roles.cache.has(indexRole.id)))) &&
(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)) {
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 () => {
console.log('[condimentX] ready event fired');
client.logger.info('Initializing CondimentX module');
if (openAI === true) {
openai = new OpenAI({ apiKey: openAIToken });
openAIWebhook = await client.fetchWebhook(openAIWebhookID, openAIWebhookToken).catch(error => {
client.logger.error(`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(`CondimentX error: 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;
};
};

472
_opt/pbUtils.js Normal file
View File

@ -0,0 +1,472 @@
// _opt/pbutils.js
/**
* PocketBase utilities module - extends PocketBase client with useful shortcuts
*/
/**
* Initializes the PocketBase utilities module
* @param {Object} client - Discord client with attached PocketBase instance
* @param {Object} config - Client configuration
*/
export const init = async (client, config) => {
const { pb, logger } = client;
logger.info('Initializing PocketBase utilities module');
// Attach utility methods to the pb object
extendPocketBase(pb, logger);
// Add connection state handling
setupConnectionHandling(pb, logger);
logger.info('PocketBase utilities module initialized');
};
/**
* Extends the PocketBase instance with utility methods
* @param {Object} pb - PocketBase instance
* @param {Object} logger - Winston logger
*/
const extendPocketBase = (pb, logger) => {
// ===== COLLECTION OPERATIONS =====
/**
* Get a single record with better error handling
* @param {string} collection - Collection name
* @param {string} id - Record ID
* @param {Object} options - Additional options
* @returns {Promise<Object>} The record or null
*/
pb.getOne = async (collection, id, options = {}) => {
try {
return await pb.collection(collection).getOne(id, options);
} catch (error) {
if (error.status === 404) {
return null;
}
logger.error(`Failed to get record ${id} from ${collection}: ${error.message}`);
throw error;
}
};
/**
* Creates a record with validation and error handling
* @param {string} collection - Collection name
* @param {Object} data - Record data
* @returns {Promise<Object>} Created record
*/
pb.createOne = async (collection, data) => {
try {
return await pb.collection(collection).create(data);
} catch (error) {
logger.error(`Failed to create record in ${collection}: ${error.message}`);
throw error;
}
};
/**
* Updates a record with better error handling
* @param {string} collection - Collection name
* @param {string} id - Record ID
* @param {Object} data - Record data
* @returns {Promise<Object>} Updated record
*/
pb.updateOne = async (collection, id, data) => {
try {
return await pb.collection(collection).update(id, data);
} catch (error) {
logger.error(`Failed to update record ${id} in ${collection}: ${error.message}`);
throw error;
}
};
/**
* Deletes a record with better error handling
* @param {string} collection - Collection name
* @param {string} id - Record ID
* @returns {Promise<boolean>} Success status
*/
pb.deleteOne = async (collection, id) => {
try {
await pb.collection(collection).delete(id);
return true;
} catch (error) {
if (error.status === 404) {
logger.warn(`Record ${id} not found in ${collection} for deletion`);
return false;
}
logger.error(`Failed to delete record ${id} from ${collection}: ${error.message}`);
throw error;
}
};
/**
* Upsert - creates or updates a record based on whether it exists
* @param {string} collection - Collection name
* @param {string} id - Record ID or null for new record
* @param {Object} data - Record data
* @returns {Promise<Object>} Created/updated record
*/
pb.upsert = async (collection, id, data) => {
if (id) {
const exists = await pb.getOne(collection, id);
if (exists) {
return await pb.updateOne(collection, id, data);
}
}
return await pb.createOne(collection, data);
};
// ===== QUERY SHORTCUTS =====
/**
* Get first record matching a filter
* @param {string} collection - Collection name
* @param {string} filter - Filter query
* @param {Object} options - Additional options
* @returns {Promise<Object>} First matching record or null
*/
pb.getFirst = async (collection, filter, options = {}) => {
try {
const result = await pb.collection(collection).getList(1, 1, {
filter,
...options
});
return result.items.length > 0 ? result.items[0] : null;
} catch (error) {
if (error.status === 404) {
return null;
}
logger.error(`Failed to get first record from ${collection}: ${error.message}`);
throw error;
}
};
/**
* Get all records from a collection (handles pagination)
* @param {string} collection - Collection name
* @param {Object} options - Query options
* @returns {Promise<Array>} Array of records
*/
pb.getAll = async (collection, options = {}) => {
const records = [];
const pageSize = options.pageSize || 200;
let page = 1;
try {
while (true) {
const result = await pb.collection(collection).getList(page, pageSize, options);
records.push(...result.items);
if (records.length >= result.totalItems) {
break;
}
page++;
}
return records;
} catch (error) {
logger.error(`Failed to get all records from ${collection}: ${error.message}`);
throw error;
}
};
/**
* Count records matching a filter
* @param {string} collection - Collection name
* @param {string} filter - Filter query
* @returns {Promise<number>} Count of matching records
*/
pb.count = async (collection, filter = '') => {
try {
const result = await pb.collection(collection).getList(1, 1, {
filter,
fields: 'id'
});
return result.totalItems;
} catch (error) {
logger.error(`Failed to count records in ${collection}: ${error.message}`);
throw error;
}
};
// ===== BATCH OPERATIONS =====
/**
* Perform batch create
* @param {string} collection - Collection name
* @param {Array<Object>} items - Array of items to create
* @returns {Promise<Array>} Created records
*/
pb.batchCreate = async (collection, items) => {
if (!items || items.length === 0) {
return [];
}
const results = [];
try {
// Process in chunks to avoid rate limits
const chunkSize = 50;
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
const promises = chunk.map(item => pb.createOne(collection, item));
const chunkResults = await Promise.all(promises);
results.push(...chunkResults);
}
return results;
} catch (error) {
logger.error(`Failed batch create in ${collection}: ${error.message}`);
throw error;
}
};
/**
* Perform batch update
* @param {string} collection - Collection name
* @param {Array<Object>} items - Array of items with id field
* @returns {Promise<Array>} Updated records
*/
pb.batchUpdate = async (collection, items) => {
if (!items || items.length === 0) {
return [];
}
const results = [];
try {
// Process in chunks to avoid rate limits
const chunkSize = 50;
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
const promises = chunk.map(item => {
const { id, ...data } = item;
return pb.updateOne(collection, id, data);
});
const chunkResults = await Promise.all(promises);
results.push(...chunkResults);
}
return results;
} catch (error) {
logger.error(`Failed batch update in ${collection}: ${error.message}`);
throw error;
}
};
/**
* Perform batch delete
* @param {string} collection - Collection name
* @param {Array<string>} ids - Array of record IDs to delete
* @returns {Promise<Array>} Results of deletion operations
*/
pb.batchDelete = async (collection, ids) => {
if (!ids || ids.length === 0) {
return [];
}
const results = [];
try {
// Process in chunks to avoid rate limits
const chunkSize = 50;
for (let i = 0; i < ids.length; i += chunkSize) {
const chunk = ids.slice(i, i + chunkSize);
const promises = chunk.map(id => pb.deleteOne(collection, id));
const chunkResults = await Promise.all(promises);
results.push(...chunkResults);
}
return results;
} catch (error) {
logger.error(`Failed batch delete in ${collection}: ${error.message}`);
throw error;
}
};
// ===== CACHE MANAGEMENT =====
// Simple in-memory cache
pb.cache = {
_store: new Map(),
_ttls: new Map(),
/**
* Get a value from cache
* @param {string} key - Cache key
* @returns {*} Cached value or undefined
*/
get(key) {
if (this._ttls.has(key) && this._ttls.get(key) < Date.now()) {
this.delete(key);
return undefined;
}
return this._store.get(key);
},
/**
* Set a value in cache
* @param {string} key - Cache key
* @param {*} value - Value to store
* @param {number} ttlSeconds - Time to live in seconds
*/
set(key, value, ttlSeconds = 300) {
this._store.set(key, value);
if (ttlSeconds > 0) {
this._ttls.set(key, Date.now() + (ttlSeconds * 1000));
}
},
/**
* Delete a value from cache
* @param {string} key - Cache key
*/
delete(key) {
this._store.delete(key);
this._ttls.delete(key);
},
/**
* Clear all cache
*/
clear() {
this._store.clear();
this._ttls.clear();
}
};
/**
* Get a record with caching
* @param {string} collection - Collection name
* @param {string} id - Record ID
* @param {number} ttlSeconds - Cache TTL in seconds
* @returns {Promise<Object>} Record or null
*/
pb.getCached = async (collection, id, ttlSeconds = 60) => {
const cacheKey = `${collection}:${id}`;
const cached = pb.cache.get(cacheKey);
if (cached !== undefined) {
return cached;
}
const record = await pb.getOne(collection, id);
pb.cache.set(cacheKey, record, ttlSeconds);
return record;
};
/**
* Get list with caching
* @param {string} collection - Collection name
* @param {Object} options - Query options
* @param {number} ttlSeconds - Cache TTL in seconds
* @returns {Promise<Object>} List result
*/
pb.getListCached = async (collection, options = {}, ttlSeconds = 30) => {
const cacheKey = `${collection}:list:${JSON.stringify(options)}`;
const cached = pb.cache.get(cacheKey);
if (cached !== undefined) {
return cached;
}
const { page = 1, perPage = 50, ...restOptions } = options;
const result = await pb.collection(collection).getList(page, perPage, restOptions);
pb.cache.set(cacheKey, result, ttlSeconds);
return result;
};
};
/**
* Setup connection state handling
* @param {Object} pb - PocketBase instance
* @param {Object} logger - Winston logger
*/
const setupConnectionHandling = (pb, logger) => {
// Add connection state tracking
pb.isConnected = true;
pb.lastSuccessfulAuth = null;
// Add auto-reconnect and token refresh
pb.authStore.onChange(() => {
pb.isConnected = pb.authStore.isValid;
if (pb.isConnected) {
pb.lastSuccessfulAuth = new Date();
logger.info('PocketBase authentication successful');
} else {
logger.warn('PocketBase auth token expired or invalid');
}
});
// Helper to check health and reconnect if needed
pb.ensureConnection = async () => {
if (!pb.isConnected || !pb.authStore.isValid) {
try {
logger.info('Reconnecting to PocketBase...');
// Attempt to refresh the auth if we have a refresh token
if (pb.authStore.token && pb.authStore.model?.id) {
await pb.admins.authRefresh();
} else if (pb._config.username && pb._config.password) {
// Fall back to full re-authentication if credentials available
await pb.admins.authWithPassword(
pb._config.username,
pb._config.password
);
} else {
logger.error('No credentials available to reconnect PocketBase');
pb.isConnected = false;
return false;
}
pb.isConnected = true;
pb.lastSuccessfulAuth = new Date();
logger.info('Successfully reconnected to PocketBase');
return true;
} catch (error) {
logger.error(`Failed to reconnect to PocketBase: ${error.message}`);
pb.isConnected = false;
return false;
}
}
return true;
};
// Store credentials for reconnection
pb._config = pb._config || {};
// Ensure only if env provided
if (process.env.SHARED_POCKETBASE_USERNAME && process.env.SHARED_POCKETBASE_PASSWORD) {
pb._config.username = process.env.SHARED_POCKETBASE_USERNAME;
pb._config.password = process.env.SHARED_POCKETBASE_PASSWORD;
}
// Heartbeat function to check connection periodically
const heartbeatInterval = setInterval(async () => {
try {
// Simple health check
await pb.health.check();
pb.isConnected = true;
} catch (error) {
logger.warn(`PocketBase connection issue: ${error.message}`);
pb.isConnected = false;
await pb.ensureConnection();
}
}, 5 * 60 * 1000); // Check every 5 minutes
// Clean up on client disconnect
pb.cleanup = () => {
clearInterval(heartbeatInterval);
};
};

399
_opt/responses.js Normal file
View File

@ -0,0 +1,399 @@
// _opt/responses.js
// Simplified OpenAI Responses module with clear flow and context management
import fs from 'fs/promises';
import path from 'path';
import { OpenAI } from 'openai';
import axios from 'axios';
import { AttachmentBuilder } from 'discord.js';
// Discord message max length
const MAX_DISCORD_MSG_LENGTH = 2000;
/**
* Split a long message into chunks <= maxLength, preserving code fences by closing and reopening.
* @param {string} text
* @param {number} maxLength
* @returns {string[]}
*/
function splitMessage(text, maxLength = MAX_DISCORD_MSG_LENGTH) {
const lines = text.split(/\n/);
const chunks = [];
let chunk = '';
let codeBlockOpen = false;
let codeBlockFence = '```';
for (let line of lines) {
const trimmed = line.trim();
const isFenceLine = trimmed.startsWith('```');
if (isFenceLine) {
if (!codeBlockOpen) {
codeBlockOpen = true;
codeBlockFence = trimmed;
} else if (trimmed === '```') {
// closing fence
codeBlockOpen = false;
}
}
// include the newline that was removed by split
const segment = line + '\n';
// if adding segment exceeds limit
if (chunk.length + segment.length > maxLength) {
if (chunk.length > 0) {
// close open code block if needed
if (codeBlockOpen) chunk += '\n```';
chunks.push(chunk);
// start new chunk, reopen code block if needed
chunk = codeBlockOpen ? (codeBlockFence + '\n' + segment) : segment;
continue;
}
// single segment too long, split it directly
let rest = segment;
while (rest.length > maxLength) {
let part = rest.slice(0, maxLength);
if (codeBlockOpen) part += '\n```';
chunks.push(part);
rest = codeBlockOpen ? (codeBlockFence + '\n' + rest.slice(maxLength)) : rest.slice(maxLength);
}
chunk = rest;
continue;
}
chunk += segment;
}
if (chunk) {
// close any unclosed code block
if (codeBlockOpen) chunk += '\n```';
chunks.push(chunk);
}
// remove trailing newline from each chunk
return chunks.map(c => c.endsWith('\n') ? c.slice(0, -1) : c);
}
/**
* Load the system prompt from disk.
*/
async function loadSystemPrompt(filePath, logger) {
try {
const prompt = await fs.readFile(path.resolve(filePath), 'utf8');
logger.info(`Loaded system prompt: ${filePath}`);
return prompt;
} catch (err) {
logger.error(`Failed to load system prompt: ${err.message}`);
return '';
}
}
/**
* Determine if the bot should respond:
* - Mentioned
* - Direct reply
*/
async function shouldRespond(message, botId, logger) {
if (message.author.bot || !botId) return false;
const isMention = message.mentions.users.has(botId);
let isReply = false;
if (message.reference?.messageId) {
try {
const ref = await message.channel.messages.fetch(message.reference.messageId);
isReply = ref.author.id === botId;
} catch {}
}
logger.debug(`Trigger? mention=${isMention} reply=${isReply}`);
return isMention || isReply;
}
/**
* Cache the last response ID for context continuity.
*/
function cacheResponse(client, key, id, ttlSeconds) {
client.pb?.cache?.set(key, id, ttlSeconds);
}
/**
* Award output tokens via the scorekeeper module.
*/
function awardOutput(client, guildId, userId, amount) {
if (client.scorekeeper && amount > 0) {
client.scorekeeper.addOutput(guildId, userId, amount)
.catch(err => client.logger.error(`Scorekeeper error: ${err.message}`));
}
}
/**
* Handle image generation function calls.
* Returns true if an image was handled and replied.
*/
async function handleImage(client, message, resp, cfg) {
const calls = Array.isArray(resp.output) ? resp.output : [];
const fn = calls.find(o => o.type === 'function_call' && o.name === 'generate_image');
if (!fn?.arguments) return false;
client.logger.debug(`Image function args: ${fn.arguments}`);
let args;
try { args = JSON.parse(fn.arguments); } catch { return false; }
if (!args.prompt?.trim()) {
await message.reply('Cannot generate image: empty prompt.');
return true;
}
// Determine image size based on aspect: square, landscape, or portrait
// Square will always use 1024x1024
let size;
switch (args.aspect) {
case 'landscape':
size = '1792x1024';
break;
case 'portrait':
size = '1024x1792';
break;
case 'square':
size = '1024x1024';
break;
default:
size = '1024x1024';
}
// Determine image quality, defaulting to cfg.imageGeneration.defaultQuality
const quality = ['standard', 'hd'].includes(args.quality)
? args.quality
: cfg.imageGeneration.defaultQuality;
try {
// Generate image via OpenAI
const imgRes = await client.openai.images.generate({ model: 'dall-e-3', prompt: args.prompt, quality: quality, size: size, n: 1 });
const url = imgRes.data?.[0]?.url;
if (!url) throw new Error('No image URL');
// Download and save locally
const dl = await axios.get(url, { responseType: 'arraybuffer' });
const buf = Buffer.from(dl.data);
const filename = `${message.author.id}-${Date.now()}.png`;
const dir = cfg.imageGeneration.imageSavePath || './images';
await fs.mkdir(dir, { recursive: true });
const filePath = path.join(dir, filename);
await fs.writeFile(filePath, buf);
client.logger.info(`Saved image: ${filePath}`);
// Reply with attachment
const attachment = new AttachmentBuilder(buf, { name: filename });
await message.reply({ content: args.prompt, files: [attachment] });
// Follow-up recap to preserve conversation context (submit function tool output)
try {
const convKey = message.thread?.id || message.channel.id;
// Build a function_call_output input item for the Responses API
const toolOutputItem = {
type: 'function_call_output',
call_id: fn.call_id,
output: JSON.stringify({ url }),
};
const recapBody = {
model: cfg.defaultModel,
// re-use original system/developer instructions
instructions: client.responsesSystemPrompt,
previous_response_id: resp.id,
input: [toolOutputItem],
max_output_tokens: Math.min(100, cfg.defaultMaxTokens),
temperature: cfg.defaultTemperature,
};
const recapResp = await client.openai.responses.create(recapBody);
cacheResponse(client, convKey, recapResp.id, Math.floor(cfg.conversationExpiry / 1000));
// Award tokens for the recap chat response
const recapTokens = recapResp.usage?.total_tokens ?? recapResp.usage?.completion_tokens ?? 0;
awardOutput(client, message.guild.id, message.author.id, recapTokens);
} catch (err) {
client.logger.error(`Recap failed: ${err.message}`);
}
} catch (err) {
client.logger.error(`Image error: ${err.message}`);
await message.reply(`Image generation error: ${err.message}`);
}
return true;
}
/**
* Main message handler:
* 1. Determine if bot should respond
* 2. Build and send AI request
* 3. Cache response ID
* 4. Handle image or text reply
* 5. Award output points
*/
async function onMessage(client, cfg, message) {
const logger = client.logger;
const botId = client.user?.id;
if (!(await shouldRespond(message, botId, logger))) return;
await message.channel.sendTyping();
// Determine channel/thread key for context
const key = message.thread?.id || message.channel.id;
// Initialize per-channel lock map
const lockMap = client._responseLockMap || (client._responseLockMap = new Map());
// Get last pending promise for this key
const last = lockMap.get(key) || Promise.resolve();
// Handler to run in sequence
const handler = async () => {
try {
// Previous response ID for context continuity
const prev = client.pb?.cache?.get(key);
// Enforce minimum score to use AI responses
try {
const scoreData = await client.scorekeeper.getScore(message.guild.id, message.author.id);
if (scoreData.totalScore < cfg.minScore) {
await message.reply(
`You need a score of at least ${cfg.minScore} to use AI responses. Your current score is ${scoreData.totalScore.toFixed(2)}.`
);
return;
}
} catch (err) {
client.logger.error(`Error checking score: ${err.message}`);
}
// Build request body, prefixing with a mention of who spoke
const speakerMention = `<@${message.author.id}>`;
const body = {
model: cfg.defaultModel,
instructions: client.responsesSystemPrompt,
input: `${speakerMention} said: ${message.content}`,
previous_response_id: prev,
max_output_tokens: cfg.defaultMaxTokens,
temperature: cfg.defaultTemperature,
};
// Assemble any enabled tools
const tools = [];
if (cfg.tools?.imageGeneration) {
tools.push({
type: 'function',
name: 'generate_image',
description: 'Generate an image with a given prompt, aspect, and quality.',
parameters: {
type: 'object',
properties: {
prompt: { type: 'string' },
aspect: { type: 'string', enum: ['square','portrait','landscape'] },
quality: { type: 'string', enum: ['standard', 'hd'] },
},
required: ['prompt','aspect','quality'],
additionalProperties: false,
},
strict: true
});
}
if (cfg.tools?.webSearch) {
tools.push({ type: 'web_search_preview' });
}
if (tools.length) {
body.tools = tools;
}
// Call OpenAI Responses
logger.debug(`Calling AI with body: ${JSON.stringify(body)}`);
const resp = await client.openai.responses.create(body);
logger.info(`AI response id=${resp.id}`);
// Award tokens for the AI chat response
const chatTokens = resp.usage?.total_tokens ?? resp.usage?.completion_tokens ?? 0;
awardOutput(client, message.guild.id, message.author.id, chatTokens);
// Cache response ID if not a function call
const isFuncCall = Array.isArray(resp.output) && resp.output.some(o => o.type === 'function_call');
if (!isFuncCall && resp.id && cfg.conversationExpiry) {
cacheResponse(client, key, resp.id, Math.floor(cfg.conversationExpiry / 1000));
}
// Handle image function call if present
if (await handleImage(client, message, resp, cfg)) return;
// Otherwise reply with text
const text = resp.output_text?.trim();
if (text) {
const parts = splitMessage(text, MAX_DISCORD_MSG_LENGTH);
for (const part of parts) {
await message.reply(part);
}
}
} catch (err) {
logger.error(`Queued onMessage error for ${key}: ${err.message}`);
}
};
// Chain the handler to the last promise
const next = last.then(handler).catch(err => logger.error(err));
lockMap.set(key, next);
// Queue enqueued; handler will send response when its turn arrives
return;
// Call OpenAI Responses
let resp;
try {
logger.debug(`Calling AI with body: ${JSON.stringify(body)}`);
resp = await client.openai.responses.create(body);
logger.info(`AI response id=${resp.id}`);
// Award tokens for the AI chat response immediately (captures token usage even if image follows)
const chatTokens = resp.usage?.total_tokens ?? resp.usage?.completion_tokens ?? 0;
awardOutput(client, message.guild.id, message.author.id, chatTokens);
} catch (err) {
logger.error(`AI error: ${err.message}`);
return message.reply('Error generating response.');
}
// Cache for next turn only if this was a text response
const isFuncCall = Array.isArray(resp.output) && resp.output.some(o => o.type === 'function_call');
if (!isFuncCall && resp.id && cfg.conversationExpiry) {
cacheResponse(client, key, resp.id, Math.floor(cfg.conversationExpiry / 1000));
}
// Handle image function call if present
if (await handleImage(client, message, resp, cfg)) return;
// Otherwise reply with text (split if over Discord limit)
const text = resp.output_text?.trim();
if (text) {
const parts = splitMessage(text, MAX_DISCORD_MSG_LENGTH);
for (const part of parts) {
await message.reply(part);
}
}
}
/**
* Send a narrative response to a specific Discord channel or thread.
* @param {import('discord.js').Client} client - Discord client instance.
* @param {Object} cfg - Responses module configuration.
* @param {string} channelId - ID of the Discord channel or thread.
* @param {string} text - Narrative input text to process.
*/
export async function sendNarrative(client, cfg, channelId, text) {
const logger = client.logger;
try {
// Build the narrative instructions
const instructions = `${client.responsesSystemPrompt}\n\nGenerate the following as an engaging narrative:`;
const body = {
model: cfg.defaultModel,
instructions,
input: text,
max_output_tokens: cfg.defaultMaxTokens,
temperature: cfg.defaultTemperature,
};
logger.debug('sendNarrative: calling AI with body', body);
const resp = await client.openai.responses.create(body);
logger.info(`sendNarrative AI response id=${resp.id}`);
// Fetch the target channel or thread
const channel = await client.channels.fetch(channelId);
if (!channel || typeof channel.send !== 'function') {
logger.error(`sendNarrative: cannot send to channel ID ${channelId}`);
return;
}
// Split the output and send
const content = resp.output_text?.trim();
if (content) {
const parts = splitMessage(content, MAX_DISCORD_MSG_LENGTH);
for (const part of parts) {
await channel.send(part);
}
}
} catch (err) {
client.logger.error(`sendNarrative error: ${err.message}`);
}
}
/**
* Initialize the Responses module
*/
export async function init(client, clientConfig) {
const cfg = clientConfig.responses;
client.logger.info('Initializing Responses module');
client.responsesSystemPrompt = await loadSystemPrompt(cfg.systemPromptPath, client.logger);
client.openai = new OpenAI({ apiKey: cfg.apiKey });
client.on('messageCreate', m => onMessage(client, cfg, m));
client.logger.info('Responses module ready');
}

230
_opt/responsesQuery.js Normal file
View File

@ -0,0 +1,230 @@
import { SlashCommandBuilder, AttachmentBuilder } from 'discord.js';
import fs from 'fs/promises';
import path from 'path';
import axios from 'axios';
/**
* Split long text into Discord-safe chunks without breaking mid-line.
* @param {string} text
* @param {number} max
* @returns {string[]}
*/
function splitLongMessage(text, max = 2000) {
const lines = text.split('\n');
const chunks = [];
let chunk = '';
for (const line of lines) {
const next = line + '\n';
if (chunk.length + next.length > max) {
chunks.push(chunk);
chunk = '';
}
chunk += next;
}
if (chunk) chunks.push(chunk);
return chunks;
}
/**
* Handle 'generate_image' function calls for slash commands.
* Returns true if image was sent.
*/
async function handleImageInteraction(client, interaction, resp, cfg, ephemeral) {
const calls = Array.isArray(resp.output) ? resp.output : [];
const fn = calls.find(o => o.type === 'function_call' && o.name === 'generate_image');
if (!fn?.arguments) return false;
client.logger.debug(`Image function args: ${fn.arguments}`);
let args;
try { args = JSON.parse(fn.arguments); } catch { return false; }
if (!args.prompt?.trim()) {
await interaction.editReply({ content: 'Cannot generate image: empty prompt.', ephemeral });
return true;
}
let size;
switch (args.aspect) {
case 'landscape': size = '1792x1024'; break;
case 'portrait': size = '1024x1792'; break;
case 'square': default: size = '1024x1024'; break;
}
const quality = ['standard', 'hd'].includes(args.quality)
? args.quality
: cfg.imageGeneration.defaultQuality;
try {
const imgRes = await client.openai.images.generate({ model: 'dall-e-3', prompt: args.prompt, quality, size, n: 1 });
const url = imgRes.data?.[0]?.url;
if (!url) throw new Error('No image URL');
const dl = await axios.get(url, { responseType: 'arraybuffer' });
const buf = Buffer.from(dl.data);
const filename = `${interaction.user.id}-${Date.now()}.png`;
const dir = cfg.imageGeneration.imageSavePath || './images';
await fs.mkdir(dir, { recursive: true });
const filePath = path.join(dir, filename);
await fs.writeFile(filePath, buf);
client.logger.info(`Saved image: ${filePath}`);
const attachment = new AttachmentBuilder(buf, { name: filename });
await interaction.editReply({ content: args.prompt, files: [attachment] });
// Recap output for context
try {
const convKey = interaction.channelId;
const toolOutputItem = {
type: 'function_call_output',
call_id: fn.call_id,
output: JSON.stringify({ url }),
};
const recapBody = {
model: cfg.defaultModel,
instructions: client.responsesSystemPrompt,
previous_response_id: resp.id,
input: [toolOutputItem],
max_output_tokens: Math.min(100, cfg.defaultMaxTokens),
temperature: cfg.defaultTemperature,
};
const recapResp = await client.openai.responses.create(recapBody);
client.pb?.cache?.set(convKey, recapResp.id, Math.floor(cfg.conversationExpiry / 1000));
const recapTokens = recapResp.usage?.total_tokens ?? recapResp.usage?.completion_tokens ?? 0;
if (client.scorekeeper && recapTokens > 0) {
client.scorekeeper.addOutput(interaction.guildId, interaction.user.id, recapTokens)
.catch(e => client.logger.error(`Scorekeeper error: ${e.message}`));
}
} catch (err) {
client.logger.error(`Recap failed: ${err.message}`);
}
return true;
} catch (err) {
client.logger.error(`Image generation error: ${err.message}`);
await interaction.editReply({ content: `Image generation error: ${err.message}`, ephemeral });
return true;
}
}
/**
* /query slash command: send a custom AI query using the Responses API.
* Options:
* prompt - Required string: the text to send to AI.
* ephemeral - Optional boolean: send response ephemerally (default: true).
*/
export const commands = [
{
data: new SlashCommandBuilder()
.setName('query')
.setDescription('Send a custom AI query')
.addStringOption(opt =>
opt.setName('prompt')
.setDescription('Your query text')
.setRequired(true)
)
.addBooleanOption(opt =>
opt.setName('ephemeral')
.setDescription('Receive an ephemeral response')
.setRequired(false)
),
async execute(interaction, client) {
const cfg = client.config.responses;
// Enforce minimum score to use /query
try {
const scoreData = await client.scorekeeper.getScore(interaction.guildId, interaction.user.id);
if (scoreData.totalScore < cfg.minScore) {
return interaction.reply({
content: `You need a score of at least ${cfg.minScore} to use /query. Your current score is ${scoreData.totalScore.toFixed(2)}.`,
ephemeral: true
});
}
} catch (err) {
client.logger.error(`Error checking score: ${err.message}`);
return interaction.reply({ content: 'Error verifying your score. Please try again later.', ephemeral: true });
}
const prompt = interaction.options.getString('prompt');
const flag = interaction.options.getBoolean('ephemeral');
const ephemeral = flag !== null ? flag : true;
await interaction.deferReply({ ephemeral });
// Determine channel/thread key for context
const key = interaction.channelId;
// Initialize per-channel lock map
const lockMap = client._responseLockMap || (client._responseLockMap = new Map());
// Get last pending promise for this key
const last = lockMap.get(key) || Promise.resolve();
// Handler to run in sequence
const handler = async () => {
// Read previous response ID
const previous = client.pb?.cache?.get(key);
// Build request body
const body = {
model: cfg.defaultModel,
instructions: client.responsesSystemPrompt,
input: prompt,
previous_response_id: previous,
max_output_tokens: cfg.defaultMaxTokens,
temperature: cfg.defaultTemperature,
};
// Assemble enabled tools
const tools = [];
if (cfg.tools?.imageGeneration) {
tools.push({
type: 'function',
name: 'generate_image',
description: 'Generate an image with a given prompt, aspect, and quality.',
parameters: {
type: 'object',
properties: {
prompt: { type: 'string' },
aspect: { type: 'string', enum: ['square','portrait','landscape'] },
quality: { type: 'string', enum: ['standard','hd'] },
},
required: ['prompt','aspect','quality'],
additionalProperties: false,
},
strict: true,
});
}
if (cfg.tools?.webSearch) {
tools.push({ type: 'web_search_preview' });
}
if (tools.length) body.tools = tools;
// Call AI
let resp;
try {
resp = await client.openai.responses.create(body);
// Award output tokens
const tokens = resp.usage?.total_tokens ?? resp.usage?.completion_tokens ?? 0;
if (client.scorekeeper && tokens > 0) {
client.scorekeeper.addOutput(interaction.guildId, interaction.user.id, tokens)
.catch(e => client.logger.error(`Scorekeeper error: ${e.message}`));
}
} catch (err) {
client.logger.error(`AI error in /query: ${err.message}`);
return interaction.editReply({ content: 'Error generating response.', ephemeral });
}
// Cache response ID if not a function call
const isFuncCall = Array.isArray(resp.output) && resp.output.some(o => o.type === 'function_call');
if (!isFuncCall && resp.id && cfg.conversationExpiry) {
client.pb?.cache?.set(key, resp.id, Math.floor(cfg.conversationExpiry / 1000));
}
// Handle image function call if present
if (await handleImageInteraction(client, interaction, resp, cfg, ephemeral)) {
return;
}
// Send text reply chunks
const text = resp.output_text?.trim() || '';
if (!text) {
return interaction.editReply({ content: 'No response generated.', ephemeral });
}
const chunks = splitLongMessage(text, 2000);
for (let i = 0; i < chunks.length; i++) {
if (i === 0) {
await interaction.editReply({ content: chunks[i] });
} else {
await interaction.followUp({ content: chunks[i], ephemeral });
}
}
};
// Chain handler after last and await
const next = last.then(handler).catch(err => client.logger.error(`Queued /query error for ${key}: ${err.message}`));
lockMap.set(key, next);
await next;
}
}
];

335
_opt/scExecHangarStatus.js Normal file
View File

@ -0,0 +1,335 @@
// _opt/schangar.js
import { SlashCommandBuilder } from 'discord.js';
// Export commands array for the centralized handler
export const commands = [
{
data: new SlashCommandBuilder()
.setName('hangarsync')
.setDescription('Mark the moment all five lights turn green, for use with hangarstatus')
.addStringOption(option =>
option.setName('timestamp')
.setDescription('Custom timestamp (Unix time in seconds or ISO 8601 format). Leave empty for current time.')
.setRequired(false)),
execute: async (interaction, client) => {
const customTimestamp = interaction.options.getString('timestamp');
let syncEpoch;
// Attempt to validate custom timestamp
if (customTimestamp) {
try {
if (/^\d+$/.test(customTimestamp)) {
const timestampInSeconds = parseInt(customTimestamp);
if (timestampInSeconds < 0 || timestampInSeconds > Math.floor(Date.now() / 1000)) {
return interaction.reply({
content: 'Invalid timestamp. Please provide a Unix time in seconds that is not in the future.',
ephemeral: true
});
}
syncEpoch = timestampInSeconds * 1000;
} else {
const date = new Date(customTimestamp);
syncEpoch = date.getTime();
if (isNaN(syncEpoch) || syncEpoch < 0) {
return interaction.reply({
content: 'Invalid timestamp format. Please use Unix time in seconds or a valid ISO 8601 string.',
ephemeral: true
});
}
}
} catch (error) {
client.logger.error(`Failed to parse timestamp in hangarsync command: ${error.message}`);
return interaction.reply({
content: 'Failed to parse timestamp. Please use Unix time in seconds or a valid ISO 8601 string.',
ephemeral: true
});
}
} else {
syncEpoch = Date.now();
}
// Check PocketBase connection status
if (!isPocketBaseConnected(client)) {
client.logger.error('PocketBase not connected when executing hangarsync command');
// Try to reconnect if available
if (typeof client.pb.ensureConnection === 'function') {
await client.pb.ensureConnection();
// Check if reconnection worked
if (!isPocketBaseConnected(client)) {
return interaction.reply({
content: 'Database connection unavailable. Please try again later.',
ephemeral: true
});
}
} else {
return interaction.reply({
content: 'Database connection unavailable. Please try again later.',
ephemeral: true
});
}
}
// Create or update timestamp for guild
try {
let record = null;
try {
// First try the enhanced method if available
if (typeof client.pb.getFirst === 'function') {
record = await client.pb.getFirst('command_hangarsync', `guildId = "${interaction.guildId}"`);
} else {
// Fall back to standard PocketBase method
const records = await client.pb.collection('command_hangarsync').getList(1, 1, {
filter: `guildId = "${interaction.guildId}"`
});
if (records.items.length > 0) {
record = records.items[0];
}
}
} catch (error) {
// Handle case where collection might not exist
client.logger.warn(`Error retrieving hangarsync record: ${error.message}`);
}
if (record) {
// Update existing record
if (typeof client.pb.updateOne === 'function') {
await client.pb.updateOne('command_hangarsync', record.id, {
userId: `${interaction.user.id}`,
epoch: `${syncEpoch}`,
});
} else {
await client.pb.collection('command_hangarsync').update(record.id, {
userId: `${interaction.user.id}`,
epoch: `${syncEpoch}`,
});
}
client.logger.info(`Updated hangarsync for guild ${interaction.guildId} by user ${interaction.user.id}`);
} else {
// Create new record
if (typeof client.pb.createOne === 'function') {
await client.pb.createOne('command_hangarsync', {
guildId: `${interaction.guildId}`,
userId: `${interaction.user.id}`,
epoch: `${syncEpoch}`,
});
} else {
await client.pb.collection('command_hangarsync').create({
guildId: `${interaction.guildId}`,
userId: `${interaction.user.id}`,
epoch: `${syncEpoch}`,
});
}
client.logger.info(`Created new hangarsync for guild ${interaction.guildId} by user ${interaction.user.id}`);
}
await interaction.reply(`Executive hangar status has been synced: <t:${Math.ceil(syncEpoch / 1000)}>`);
} catch (error) {
client.logger.error(`Error in hangarsync command: ${error.message}`);
await interaction.reply({
content: `Error syncing hangar status. Please try again later.`,
ephemeral: true
});
}
}
},
{
data: new SlashCommandBuilder()
.setName('hangarstatus')
.setDescription('Check the status of contested zone executive hangars')
.addBooleanOption(option =>
option.setName('verbose')
.setDescription('Extra output, mainly for debugging.')
.setRequired(false)),
execute: async (interaction, client) => {
const verbose = interaction.options.getBoolean('verbose');
// Check PocketBase connection status
if (!isPocketBaseConnected(client)) {
client.logger.error('PocketBase not connected when executing hangarstatus command');
// Try to reconnect if available
if (typeof client.pb.ensureConnection === 'function') {
await client.pb.ensureConnection();
// Check if reconnection worked
if (!isPocketBaseConnected(client)) {
return interaction.reply({
content: 'Database connection unavailable. Please try again later.',
ephemeral: true
});
}
} else {
return interaction.reply({
content: 'Database connection unavailable. Please try again later.',
ephemeral: true
});
}
}
try {
// Get hangarsync data for guild
let hangarSync = null;
try {
// First try the enhanced method if available
if (typeof client.pb.getFirst === 'function') {
hangarSync = await client.pb.getFirst('command_hangarsync', `guildId = "${interaction.guildId}"`);
} else {
// Fall back to standard PocketBase methods
try {
hangarSync = await client.pb.collection('command_hangarsync').getFirstListItem(`guildId = "${interaction.guildId}"`);
} catch (error) {
// getFirstListItem throws if no items found
if (error.status !== 404) throw error;
}
}
if (!hangarSync) {
client.logger.info(`No sync data found for guild ${interaction.guildId}`);
return interaction.reply({
content: 'No sync data found. Please use `/hangarsync` first to establish a reference point.',
ephemeral: true
});
}
} catch (error) {
client.logger.info(`Error retrieving sync data for guild ${interaction.guildId}: ${error.message}`);
return interaction.reply({
content: 'No sync data found. Please use `/hangarsync` first to establish a reference point.',
ephemeral: true
});
}
const currentTime = Date.now();
// 5 minutes (all off) + 5*24 minutes (turning green) + 5*12 minutes (turning off) = 185 minutes
const cycleDuration = 5 + (5 * 24) + (5 * 12);
// Key positions in the cycle
const allOffDuration = 5;
const turningGreenDuration = 5 * 24;
const turningOffDuration = 5 * 12;
// Calculate how much time has passed since the epoch
const timeSinceEpoch = (currentTime - hangarSync.epoch) / (60 * 1000);
// Calculate where we are in the full-cycle relative to the epoch
const cyclePosition = ((timeSinceEpoch % cycleDuration) + cycleDuration) % cycleDuration;
// Initialize stuff and things
const lights = [":black_circle:", ":black_circle:", ":black_circle:", ":black_circle:", ":black_circle:"];
let minutesUntilNextPhase = 0;
let currentPhase = "";
// If the epoch is now, we should be at the all-green position.
// From there, we need to determine where we are in the cycle.
// Case 1: We're in the unlocked phase, right after epoch
if (cyclePosition < turningOffDuration) {
currentPhase = "Unlocked";
// All lights start as green
lights.fill(":green_circle:");
// Calculate how many lights have turned off
const offLights = Math.floor(cyclePosition / 12);
// Set the appropriate number of lights to off
for (let i = 0; i < offLights; i++) {
lights[i] = ":black_circle:";
}
// Calculate time until next light turns off
const timeUntilNextLight = 12 - (cyclePosition % 12);
minutesUntilNextPhase = timeUntilNextLight;
}
// Case 2: We're in the reset phase
else if (cyclePosition < turningOffDuration + allOffDuration) {
currentPhase = "Resetting";
// Lights are initialized "off", so do nothing with them
// Calculate time until all lights turn red
const timeIntoPhase = cyclePosition - turningOffDuration;
minutesUntilNextPhase = allOffDuration - timeIntoPhase;
}
// Case 3: We're in the locked phase
else {
currentPhase = "Locked";
// All lights start as red
lights.fill(":red_circle:");
// Calculate how many lights have turned green
const timeIntoPhase = cyclePosition - (turningOffDuration + allOffDuration);
const greenLights = Math.floor(timeIntoPhase / 24);
// Set the appropriate number of lights to green
for (let i = 0; i < greenLights; i++) {
lights[i] = ":green_circle:";
}
// Calculate time until next light turns green
const timeUntilNextLight = 24 - (timeIntoPhase % 24);
minutesUntilNextPhase = timeUntilNextLight;
}
// Calculate a timestamp for Discord's formatting and reply
const expiration = Math.ceil((Date.now() / 1000) + (minutesUntilNextPhase * 60));
await interaction.reply(`### ${lights[0]} ${lights[1]} ${lights[2]} ${lights[3]} ${lights[4]}`);
if (verbose) {
await interaction.followUp(`- **Phase**: ${currentPhase}\n- **Status Expiration**: <t:${expiration}:R>\n- **Epoch**: <t:${Math.ceil(hangarSync.epoch / 1000)}:R>\n- **Sync**: <t:${Math.floor(new Date(hangarSync.updated).getTime() / 1000)}:R> by <@${hangarSync.userId}>`);
// Add additional debug info to logs
client.logger.debug(`Hangarstatus for guild ${interaction.guildId}: Phase=${currentPhase}, CyclePosition=${cyclePosition}, TimeSinceEpoch=${timeSinceEpoch}`);
}
} catch (error) {
client.logger.error(`Error in hangarstatus command: ${error.message}`);
await interaction.reply({
content: `Error retrieving hangar status. Please try again later.`,
ephemeral: true
});
}
}
}
];
// Function to check PocketBase connection status
function isPocketBaseConnected(client) {
// Check multiple possible status indicators to be safe
return client.pb && (
// Check status object (original code style)
(client.pb.status && client.pb.status.connected) ||
// Check isConnected property (pbutils module style)
client.pb.isConnected === true ||
// Last resort: check if authStore is valid
client.pb.authStore?.isValid === true
);
}
// Initialize module
export const init = async (client, config) => {
client.logger.info('Initializing Star Citizen Hangar Status module');
// Check PocketBase connection
if (!isPocketBaseConnected(client)) {
client.logger.warn('PocketBase not connected at initialization');
// Try to reconnect if available
if (typeof client.pb.ensureConnection === 'function') {
await client.pb.ensureConnection();
}
} else {
client.logger.info('PocketBase connection confirmed');
}
client.logger.info('Star Citizen Hangar Status module initialized');
};

146
_opt/scorekeeper-example.js Normal file
View File

@ -0,0 +1,146 @@
// Example of another module using scorekeeper
export const init = async (client, config) => {
// Set up message listener that adds input points when users chat
client.on('messageCreate', async (message) => {
if (message.author.bot) return;
// Skip if not in a guild
if (!message.guild) return;
// Add input points based on message length
const points = Math.min(Math.ceil(message.content.length / 10), 5);
try {
await client.scorekeeper.addInput(message.guild.id, message.author.id, points);
} catch (error) {
client.logger.error(`Error adding input points: ${error.message}`);
}
});
// Initialize voice tracking state
client.voiceTracker = {
joinTimes: new Map(), // Tracks when users joined voice
activeUsers: new Map() // Tracks users currently earning points
};
// Set up a voice state listener that adds input for voice activity
client.on('voiceStateUpdate', async (oldState, newState) => {
// Skip if not in a guild
if (!oldState.guild && !newState.guild) return;
const guild = oldState.guild || newState.guild;
const member = oldState.member || newState.member;
// User joined a voice channel
if (!oldState.channelId && newState.channelId) {
// Check if the channel has other non-bot users
const channel = newState.channel;
const otherUsers = channel.members.filter(m =>
m.id !== member.id && !m.user.bot
);
// Store join time if there's at least one other non-bot user
if (otherUsers.size > 0) {
client.voiceTracker.joinTimes.set(member.id, Date.now());
client.voiceTracker.activeUsers.set(member.id, newState.channelId);
client.logger.debug(`${member.user.tag} joined voice with others - tracking time`);
} else {
client.logger.debug(`${member.user.tag} joined voice alone or with bots - not tracking time`);
}
}
// User left a voice channel
else if (oldState.channelId && !newState.channelId) {
processVoiceLeave(client, guild, member, oldState.channelId);
}
// User switched voice channels
else if (oldState.channelId && newState.channelId && oldState.channelId !== newState.channelId) {
// Process leaving the old channel
processVoiceLeave(client, guild, member, oldState.channelId);
// Check if the new channel has other non-bot users
const channel = newState.channel;
const otherUsers = channel.members.filter(m =>
m.id !== member.id && !m.user.bot
);
// Start tracking in the new channel if there are other non-bot users
if (otherUsers.size > 0) {
client.voiceTracker.joinTimes.set(member.id, Date.now());
client.voiceTracker.activeUsers.set(member.id, newState.channelId);
}
}
// If someone joined or left a channel, update tracking for everyone in that channel
updateChannelUserTracking(client, oldState, newState);
});
};
/**
* Process when a user leaves a voice channel
*/
function processVoiceLeave(client, guild, member, channelId) {
if (client.voiceTracker.activeUsers.get(member.id) === channelId) {
const joinTime = client.voiceTracker.joinTimes.get(member.id);
if (joinTime) {
const duration = (Date.now() - joinTime) / 1000 / 60; // Duration in minutes
// Award 1 point per minute, up to 30 per session
const points = Math.min(Math.floor(duration), 30);
if (points > 0) {
try {
client.scorekeeper.addInput(guild.id, member.id, points)
.then(() => {
client.logger.debug(`Added ${points} voice activity points for ${member.user.tag}`);
})
.catch(error => {
client.logger.error(`Error adding voice points: ${error.message}`);
});
} catch (error) {
client.logger.error(`Error adding voice points: ${error.message}`);
}
}
}
client.voiceTracker.joinTimes.delete(member.id);
client.voiceTracker.activeUsers.delete(member.id);
}
}
/**
* Updates tracking for all users in affected channels
*/
function updateChannelUserTracking(client, oldState, newState) {
// Get the affected channels
const affectedChannels = new Set();
if (oldState.channelId) affectedChannels.add(oldState.channelId);
if (newState.channelId) affectedChannels.add(newState.channelId);
for (const channelId of affectedChannels) {
const channel = oldState.guild.channels.cache.get(channelId);
if (!channel) continue;
// Check if the channel has at least 2 non-bot users
const nonBotMembers = channel.members.filter(m => !m.user.bot);
const hasMultipleUsers = nonBotMembers.size >= 2;
// For each user in the channel
channel.members.forEach(channelMember => {
if (channelMember.user.bot) return; // Skip bots
const userId = channelMember.id;
const isActive = client.voiceTracker.activeUsers.get(userId) === channelId;
// Should be active but isn't yet
if (hasMultipleUsers && !isActive) {
client.voiceTracker.joinTimes.set(userId, Date.now());
client.voiceTracker.activeUsers.set(userId, channelId);
client.logger.debug(`Starting tracking for ${channelMember.user.tag} in ${channel.name}`);
}
// Should not be active but is
else if (!hasMultipleUsers && isActive) {
processVoiceLeave(client, oldState.guild, channelMember, channelId);
client.logger.debug(`Stopping tracking for ${channelMember.user.tag} - not enough users`);
}
});
}
}

594
_opt/scorekeeper.js Normal file
View File

@ -0,0 +1,594 @@
// opt/scorekeeper.js
import cron from 'node-cron';
import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } from 'discord.js';
// Module state container
const moduleState = {
cronJobs: new Map(), // Store cron jobs by client ID
};
/**
* Initialize the scorekeeper module
*/
export const init = async (client, config) => {
client.logger.info('Initializing Scorekeeper module');
// Check if configuration exists
if (!config.scorekeeper) {
client.logger.warn('Scorekeeper configuration missing, using defaults');
config.scorekeeper = {
baseOutput: 1000,
commendationValue: 1.0,
citationValue: 1.2,
decay: 90,
schedule: '0 0 * * 0' // Default: weekly at midnight on Sunday
};
}
// Check if scorekeeper collection exists
await checkCollection(client);
// Create scorekeeper interface on client
client.scorekeeper = {
addInput: (guildId, userId, amount) => addInput(client, guildId, userId, amount),
addOutput: (guildId, userId, amount) => addOutput(client, guildId, userId, amount),
addCommendation: (guildId, userId, amount = 1) => addCommendation(client, guildId, userId, amount),
addCitation: (guildId, userId, amount = 1) => addCitation(client, guildId, userId, amount),
getScore: (guildId, userId) => getScore(client, guildId, userId),
getScores: (guildId, limit = 10) => getScores(client, guildId, limit),
runDecay: (guildId) => runDecay(client, guildId)
};
// Set up cron job for decay
setupDecayCron(client, config.scorekeeper.schedule);
client.logger.info('Scorekeeper module initialized');
};
/**
* Check if the scorekeeper collection exists in PocketBase
*/
async function checkCollection(client) {
try {
// Check if collection exists by trying to list records
await client.pb.collection('scorekeeper').getList(1, 1);
client.logger.info('Scorekeeper collection exists in PocketBase');
} catch (error) {
// If collection doesn't exist, log warning
client.logger.warn('Scorekeeper collection does not exist in PocketBase');
client.logger.warn('Please create a "scorekeeper" collection with fields:');
client.logger.warn('- guildId (text, required)');
client.logger.warn('- userId (text, required)');
client.logger.warn('- input (number, default: 0)');
client.logger.warn('- output (number, default: 0)');
client.logger.warn('- commendations (number, default: 0)');
client.logger.warn('- citations (number, default: 0)');
client.logger.warn('- lastDecay (date, required)');
}
}
/**
* Set up cron job for decay
*/
function setupDecayCron(client, schedule) {
try {
// Validate cron expression
if (!cron.validate(schedule)) {
client.logger.error(`Invalid cron schedule: ${schedule}, using default`);
schedule = '0 0 * * 0'; // Default: weekly at midnight on Sunday
}
// Create and start the cron job
const job = cron.schedule(schedule, async () => {
client.logger.info('Running scheduled score decay');
try {
// Get all guilds the bot is in
const guilds = client.guilds.cache.map(guild => guild.id);
// Run decay for each guild
for (const guildId of guilds) {
await runDecay(client, guildId);
}
client.logger.info('Score decay completed');
} catch (error) {
client.logger.error(`Error during scheduled score decay: ${error.message}`);
}
});
// Store the job in module state
moduleState.cronJobs.set(client.config.id, job);
client.logger.info(`Score decay scheduled with cron: ${schedule}`);
} catch (error) {
client.logger.error(`Failed to set up decay cron job: ${error.message}`);
}
}
/**
* Add input points for a user
*/
async function addInput(client, guildId, userId, amount) {
if (!guildId || !userId || !amount || amount <= 0) {
throw new Error(`Invalid parameters for addInput - guildId: ${guildId}, userId: ${userId}, amount: ${amount}`);
}
try {
// Get or create user record
const scoreData = await getOrCreateScoreData(client, guildId, userId);
// Calculate new input score
const newInput = scoreData.input + amount;
client.logger.debug(`Updating record ${scoreData.id} - input from ${scoreData.input} to ${newInput}`);
// Use direct update with ID to avoid duplicate records
return await client.pb.collection('scorekeeper').update(scoreData.id, {
input: newInput
});
} catch (error) {
client.logger.error(`Error adding input points: ${error.message}`);
throw error;
}
}
/**
* Add output points for a user
*/
async function addOutput(client, guildId, userId, amount) {
if (!guildId || !userId || !amount || amount <= 0) {
throw new Error('Invalid parameters for addOutput');
}
try {
// Get or create user record
const scoreData = await getOrCreateScoreData(client, guildId, userId);
// Calculate new output score
const newOutput = scoreData.output + amount;
client.logger.debug(`Updating record ${scoreData.id} - output from ${scoreData.output} to ${newOutput}`);
// Use direct update with ID to avoid duplicate records
return await client.pb.collection('scorekeeper').update(scoreData.id, {
output: newOutput
});
} catch (error) {
client.logger.error(`Error adding output points: ${error.message}`);
throw error;
}
}
/**
* Add commendations for a user
*/
async function addCommendation(client, guildId, userId, amount = 1) {
if (!guildId || !userId || amount <= 0) {
throw new Error('Invalid parameters for addCommendation');
}
try {
// Log the search to debug
client.logger.debug(`Looking for record with guildId=${guildId} and userId=${userId}`);
// Get or create user record
const scoreData = await getOrCreateScoreData(client, guildId, userId);
// Log the found/created record
client.logger.debug(`Found/created record with ID: ${scoreData.id}`);
// Calculate new commendations value
const newCommendations = scoreData.commendations + amount;
// Log the update attempt
client.logger.debug(`Updating record ${scoreData.id} - commendations from ${scoreData.commendations} to ${newCommendations}`);
// Use direct update with ID to avoid duplicate records
return await client.pb.collection('scorekeeper').update(scoreData.id, {
commendations: newCommendations
});
} catch (error) {
client.logger.error(`Error adding commendations: ${error.message}`);
throw error;
}
}
/**
* Add citations for a user
*/
async function addCitation(client, guildId, userId, amount = 1) {
if (!guildId || !userId || amount <= 0) {
throw new Error('Invalid parameters for addCitation');
}
try {
// Get or create user record
const scoreData = await getOrCreateScoreData(client, guildId, userId);
// Calculate new citations value
const newCitations = scoreData.citations + amount;
client.logger.debug(`Updating record ${scoreData.id} - citations from ${scoreData.citations} to ${newCitations}`);
// Use direct update with ID to avoid duplicate records
return await client.pb.collection('scorekeeper').update(scoreData.id, {
citations: newCitations
});
} catch (error) {
client.logger.error(`Error adding citations: ${error.message}`);
throw error;
}
}
/**
* Get a user's score
*/
async function getScore(client, guildId, userId) {
if (!guildId || !userId) {
throw new Error('Guild ID and User ID are required');
}
try {
// Get score data, create if not exists
const scoreData = await getOrCreateScoreData(client, guildId, userId);
// Calculate total score
const totalScore = calculateScore(
scoreData,
client.config.scorekeeper.baseOutput,
client.config.scorekeeper.commendationValue,
client.config.scorekeeper.citationValue
);
return {
...scoreData,
totalScore
};
} catch (error) {
client.logger.error(`Error getting score: ${error.message}`);
throw error;
}
}
/**
* Get scores for a guild
*/
async function getScores(client, guildId, limit = 10) {
if (!guildId) {
throw new Error('Guild ID is required');
}
try {
// Fetch all score records for this guild
const records = await client.pb.collection('scorekeeper').getFullList({
filter: `guildId = "${guildId}"`
});
// Compute totalScore for each, then sort and limit
const scored = records.map(record => {
const totalScore = calculateScore(
record,
client.config.scorekeeper.baseOutput,
client.config.scorekeeper.commendationValue,
client.config.scorekeeper.citationValue
);
return { ...record, totalScore };
});
// Sort descending by score
scored.sort((a, b) => b.totalScore - a.totalScore);
// Return top 'limit' entries
return scored.slice(0, limit);
} catch (error) {
client.logger.error(`Error getting scores: ${error.message}`);
throw error;
}
}
/**
* Run decay process for a guild
*/
async function runDecay(client, guildId) {
if (!guildId) {
throw new Error('Guild ID is required');
}
try {
const decayFactor = client.config.scorekeeper.decay / 100;
const baseOutput = client.config.scorekeeper.baseOutput;
// Get all records for this guild
const records = await client.pb.collection('scorekeeper').getFullList({
filter: `guildId = "${guildId}"`
});
// Update each record with decay
let updatedCount = 0;
for (const record of records) {
try {
const newInput = Math.floor(record.input * decayFactor);
// Calculate new output, but ensure it never falls below baseOutput
let newOutput = Math.floor(record.output * decayFactor);
if (newOutput < baseOutput) {
newOutput = baseOutput;
client.logger.debug(`Output for record ${record.id} would fall below BaseOutput - setting to ${baseOutput}`);
}
// Update record directly
await client.pb.collection('scorekeeper').update(record.id, {
input: newInput,
output: newOutput,
lastDecay: new Date().toISOString()
});
updatedCount++;
} catch (updateError) {
client.logger.error(`Error updating record ${record.id} during decay: ${updateError.message}`);
}
}
client.logger.info(`Decay completed for guild ${guildId}: ${updatedCount} records updated`);
return updatedCount;
} catch (error) {
client.logger.error(`Error running decay: ${error.message}`);
throw error;
}
}
/**
* Calculate score based on formula
*/
function calculateScore(data, baseOutput, commendationValue, citationValue) {
// Score = ((Commendations * CommendationValue) - (Citations * CitationValue)) + (Input / (Output + BaseOutput))
const permanentModifier = (data.commendations * commendationValue) - (data.citations * citationValue);
const activityScore = data.input / (data.output + baseOutput);
return permanentModifier + activityScore;
}
/**
* Get or create score data for a user in a guild
*/
async function getOrCreateScoreData(client, guildId, userId) {
try {
// Always try to get existing record first
let existingRecord = null;
const baseOutput = client.config.scorekeeper.baseOutput;
// Try to find the record using filter
try {
existingRecord = await client.pb.collection('scorekeeper').getFirstListItem(
`guildId = "${guildId}" && userId = "${userId}"`
);
client.logger.debug(`Found existing score record for ${userId} in guild ${guildId}`);
return existingRecord;
} catch (error) {
// Only create new record if specifically not found (404)
if (error.status === 404) {
client.logger.debug(`No existing score record found, creating new one for ${userId} in guild ${guildId}`);
// Create new record with default values
// Note: output is now initialized to baseOutput instead of 0
const newData = {
guildId,
userId,
input: 0,
output: baseOutput, // Initialize to baseOutput, not 0
commendations: 0,
citations: 0,
lastDecay: new Date().toISOString()
};
return await client.pb.collection('scorekeeper').create(newData);
}
// For any other error, rethrow
client.logger.error(`Error searching for score record: ${error.message}`);
throw error;
}
} catch (error) {
client.logger.error(`Error in getOrCreateScoreData: ${error.message}`);
throw error;
}
}
// Define slash commands for the module
export const commands = [
// Command to view a user's score
{
data: new SlashCommandBuilder()
.setName('score')
.setDescription('View your score or another user\'s score')
.addUserOption(option =>
option.setName('user')
.setDescription('User to check score for (defaults to you)')
.setRequired(false)
)
.addBooleanOption(option =>
option.setName('ephemeral')
.setDescription('Whether the response should be ephemeral')
.setRequired(false)
),
execute: async (interaction, client) => {
const targetUser = interaction.options.getUser('user') || interaction.user;
const ephemeral = interaction.options.getBoolean('ephemeral') ?? true;
try {
const scoreData = await client.scorekeeper.getScore(interaction.guildId, targetUser.id);
const embed = new EmbedBuilder()
.setTitle(`Score for ${targetUser.username}`)
.setColor(0x00AE86)
.setThumbnail(targetUser.displayAvatarURL())
.addFields(
{ name: 'Total Score', value: scoreData.totalScore.toFixed(2), inline: false },
{ name: 'Commendations', value: scoreData.commendations.toString(), inline: false },
{ name: 'Citations', value: scoreData.citations.toString(), inline: false },
{ name: 'Input Score', value: scoreData.input.toString(), inline: true },
{ name: 'Output Score', value: scoreData.output.toString(), inline: true }
)
.setFooter({ text: 'Last decay: ' + new Date(scoreData.lastDecay).toLocaleDateString() })
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral });
} catch (error) {
client.logger.error(`Error in score command: ${error.message}`);
await interaction.reply({
content: 'Failed to retrieve score data.',
ephemeral
});
}
}
},
// Command to view top scores
{
data: new SlashCommandBuilder()
.setName('leaderboard')
.setDescription('View the server\'s score leaderboard')
.addIntegerOption(option =>
option.setName('limit')
.setDescription('Number of users to show (default: 10, max: 25)')
.setRequired(false)
.setMinValue(1)
.setMaxValue(25)
)
.addBooleanOption(option =>
option.setName('ephemeral')
.setDescription('Whether the response should be ephemeral')
.setRequired(false)
),
execute: async (interaction, client) => {
const ephemeral = interaction.options.getBoolean('ephemeral') ?? true;
await interaction.deferReply({ ephemeral });
const limit = interaction.options.getInteger('limit') || 10;
try {
const scores = await client.scorekeeper.getScores(interaction.guildId, limit);
if (scores.length === 0) {
return interaction.editReply('No scores found for this server.');
}
// Format leaderboard
const guild = interaction.guild;
let leaderboardText = '';
for (let i = 0; i < scores.length; i++) {
const score = scores[i];
const user = await guild.members.fetch(score.userId).catch(() => null);
const username = user ? user.user.username : 'Unknown User';
leaderboardText += `${i + 1}. **${username}**: ${score.totalScore.toFixed(2)}\n`;
}
const embed = new EmbedBuilder()
.setTitle(`${guild.name} Score Leaderboard`)
.setColor(0x00AE86)
.setDescription(leaderboardText)
.setFooter({ text: `Showing top ${scores.length} users` })
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
} catch (error) {
client.logger.error(`Error in leaderboard command: ${error.message}`);
await interaction.editReply('Failed to retrieve leaderboard data.');
}
}
},
// Command to give a commendation (admin only)
{
data: new SlashCommandBuilder()
.setName('commend')
.setDescription('Give a commendation to a user (Admin only)')
.addUserOption(option =>
option.setName('user')
.setDescription('User to commend')
.setRequired(true))
.addIntegerOption(option =>
option.setName('amount')
.setDescription('Amount of commendations (default: 1)')
.setRequired(false)
.setMinValue(1)
.setMaxValue(10))
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction, client) => {
const targetUser = interaction.options.getUser('user');
const amount = interaction.options.getInteger('amount') || 1;
try {
await client.scorekeeper.addCommendation(interaction.guildId, targetUser.id, amount);
await interaction.reply(`Added ${amount} commendation${amount !== 1 ? 's' : ''} to ${targetUser}.`);
} catch (error) {
client.logger.error(`Error in commend command: ${error.message}`);
await interaction.reply({
content: 'Failed to add commendation.',
ephemeral: true
});
}
}
},
// Command to give a citation (admin only)
{
data: new SlashCommandBuilder()
.setName('cite')
.setDescription('Give a citation to a user (Admin only)')
.addUserOption(option =>
option.setName('user')
.setDescription('User to cite')
.setRequired(true))
.addIntegerOption(option =>
option.setName('amount')
.setDescription('Amount of citations (default: 1)')
.setRequired(false)
.setMinValue(1)
.setMaxValue(10))
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction, client) => {
const targetUser = interaction.options.getUser('user');
const amount = interaction.options.getInteger('amount') || 1;
try {
await client.scorekeeper.addCitation(interaction.guildId, targetUser.id, amount);
await interaction.reply(`Added ${amount} citation${amount !== 1 ? 's' : ''} to ${targetUser}.`);
} catch (error) {
client.logger.error(`Error in cite command: ${error.message}`);
await interaction.reply({
content: 'Failed to add citation.',
ephemeral: true
});
}
}
},
// Command to manually run decay (admin only)
{
data: new SlashCommandBuilder()
.setName('run-decay')
.setDescription('Manually run score decay (Admin only)')
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction, client) => {
await interaction.deferReply();
try {
const updatedCount = await client.scorekeeper.runDecay(interaction.guildId);
await interaction.editReply(`Score decay completed. Updated ${updatedCount} user records.`);
} catch (error) {
client.logger.error(`Error in run-decay command: ${error.message}`);
await interaction.editReply('Failed to run score decay.');
}
}
}
];

72
_src/loader.js Normal file
View File

@ -0,0 +1,72 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.dirname(__dirname);
// Load modules function - hot reload functionality removed
export const loadModules = async (clientConfig, client) => {
const modules = clientConfig.modules || [];
const modulesDir = path.join(rootDir, '_opt');
// Create opt directory if it doesn't exist
if (!fs.existsSync(modulesDir)) {
fs.mkdirSync(modulesDir, { recursive: true });
}
// Load each module
for (const moduleName of modules) {
try {
const modulePath = path.join(modulesDir, `${moduleName}.js`);
// Check if module exists
if (!fs.existsSync(modulePath)) {
client.logger.warn(`Module not found: ${modulePath}`);
continue;
}
// Import module (using dynamic import for ES modules)
// Import module
const moduleUrl = `file://${modulePath}`;
const module = await import(moduleUrl);
// Register commands if the module has them
if (module.commands) {
if (Array.isArray(module.commands)) {
// Handle array of commands
for (const command of module.commands) {
if (command.data && typeof command.execute === 'function') {
const commandName = command.data.name || command.name;
client.commands.set(commandName, command);
client.logger.info(`Registered command: ${commandName}`);
}
}
} else if (typeof module.commands === 'object') {
// Handle map/object of commands
for (const [commandName, command] of Object.entries(module.commands)) {
if (command.execute && typeof command.execute === 'function') {
client.commands.set(commandName, command);
client.logger.info(`Registered command: ${commandName}`);
}
}
}
}
// Call init function if it exists
if (typeof module.init === 'function') {
await module.init(client, clientConfig);
client.logger.info(`Module loaded: ${moduleName}`);
} else {
client.logger.info(`Module loaded (no init function): ${moduleName}`);
}
// Store the module reference (this isn't used for hot reloading anymore)
client.modules = client.modules || new Map();
client.modules.set(moduleName, module);
} catch (error) {
client.logger.error(`Failed to load module ${moduleName}: ${error.message}`);
}
}
};

86
_src/logger.js Normal file
View File

@ -0,0 +1,86 @@
import winston from 'winston';
import 'winston-daily-rotate-file';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.dirname(__dirname);
// Create Winston logger
export const createLogger = (clientConfig) => {
const { logging } = clientConfig;
const transports = [];
// Console transport
if (logging.console.enabled) {
transports.push(new winston.transports.Console({
level: logging.console.level,
format: winston.format.combine(
winston.format.timestamp({
format: logging.file.timestampFormat
}),
logging.console.colorize ? winston.format.colorize() : winston.format.uncolorize(),
winston.format.printf(info => `[${info.timestamp}] [${clientConfig.id}] [${info.level}] ${info.message}`)
)
}));
}
// Combined file transport with rotation
if (logging.file.combined.enabled) {
const logDir = path.join(rootDir, logging.file.combined.location);
// Create log directory if it doesn't exist
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
const combinedTransport = new winston.transports.DailyRotateFile({
filename: path.join(logDir, `${clientConfig.id}-combined-%DATE%.log`),
datePattern: logging.file.dateFormat,
level: logging.file.combined.level,
maxSize: logging.file.combined.maxSize,
maxFiles: logging.file.combined.maxFiles,
format: winston.format.combine(
winston.format.timestamp({
format: logging.file.timestampFormat
}),
winston.format.printf(info => `[${info.timestamp}] [${info.level}] ${info.message}`)
)
});
transports.push(combinedTransport);
}
// Error file transport with rotation
if (logging.file.error.enabled) {
const logDir = path.join(rootDir, logging.file.error.location);
// Create log directory if it doesn't exist
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
const errorTransport = new winston.transports.DailyRotateFile({
filename: path.join(logDir, `${clientConfig.id}-error-%DATE%.log`),
datePattern: logging.file.dateFormat,
level: logging.file.error.level,
maxSize: logging.file.error.maxSize,
maxFiles: logging.file.error.maxFiles,
format: winston.format.combine(
winston.format.timestamp({
format: logging.file.timestampFormat
}),
winston.format.printf(info => `[${info.timestamp}] [${info.level}] ${info.message}`)
)
});
transports.push(errorTransport);
}
return winston.createLogger({
levels: winston.config.npm.levels,
transports
});
};

20
_src/pocketbase.js Normal file
View File

@ -0,0 +1,20 @@
import PocketBase from 'pocketbase';
// Initialize Pocketbase
export const initializePocketbase = async (clientConfig, logger) => {
try {
const pb = new PocketBase(clientConfig.pocketbase.url);
// Authenticate with admin credentials
await pb.collection('_users').authWithPassword(
clientConfig.pocketbase.username,
clientConfig.pocketbase.password
);
logger.info('PocketBase initialized and authenticated');
return pb;
} catch (error) {
logger.error(`PocketBase initialization failed: ${error.message}`);
return new PocketBase(clientConfig.pocketbase.url);
}
};

62
assets/io.eps Normal file
View File

@ -0,0 +1,62 @@
%!PS-Adobe-3.0 EPSF-3.0
%%HiResBoundingBox: 0 0 245.76 245.76
%%BoundingBox: 0 0 246 246
%%Creator: Serif Affinity
%LanguageLevel: 3
%%DocumentData: Clean7Bit
%%EndComments
%%BeginProlog
101 dict begin
/m/moveto
/L/rlineto
/C/rcurveto
/q/gsave
/Q/grestore
/n/newpath
/h/closepath
/f/fill
/f*/eofill
/S/stroke
/w/setlinewidth
/J/setlinecap
/j/setlinejoin
/ml/setmiterlimit
/d/setdash
/sc/setcolor
/scs/setcolorspace
17{load def}repeat
%%EndProlog
q
/DeviceRGB scs
n
122.88 235.418 m
62.112 0 112.538 -50.426 112.538 -112.538 C
0 -62.112 -50.426 -112.538 -112.538 -112.538 C
-62.112 0 -112.538 50.426 -112.538 112.538 C
0 62.112 50.426 112.538 112.538 112.538 C
h
122.88 206.901 m
-46.372 0 -84.021 -37.649 -84.021 -84.021 C
0 -46.372 37.649 -84.021 84.021 -84.021 C
46.372 0 84.021 37.649 84.021 84.021 C
0 46.372 -37.649 84.021 -84.021 84.021 C
h
1 0.259 0 sc
f*
122.88 208.45 m
47.227 0 85.57 -38.343 85.57 -85.57 C
0 -47.227 -38.343 -85.57 -85.57 -85.57 C
-47.227 0 -85.57 38.343 -85.57 85.57 C
0 47.227 38.343 85.57 85.57 85.57 C
h
122.88 186.766 m
-35.26 0 -63.886 -28.626 -63.886 -63.886 C
0 -35.26 28.626 -63.886 63.886 -63.886 C
35.26 0 63.886 28.626 63.886 63.886 C
0 35.26 -28.626 63.886 -63.886 63.886 C
h
1 0.882 0.651 sc
f*
Q
showpage
end

BIN
assets/io.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

132
config.js.sample Normal file
View File

@ -0,0 +1,132 @@
import dotenv from 'dotenv';
dotenv.config();
/**
* Sample configuration for the ClientX Discord bot.
* Copy this file to config.js and replace placeholder values with your own.
*/
export default {
clients: [
{
// Unique identifier for this client
id: 'YOUR_CLIENT_ID',
enabled: true,
// Discord user ID of the bot owner
owner: 'YOUR_DISCORD_USER_ID',
// Discord application credentials
discord: {
appId: 'YOUR_DISCORD_APP_ID',
token: 'YOUR_DISCORD_BOT_TOKEN',
},
// Logging configuration
logging: {
console: {
enabled: true,
colorize: true,
level: 'info',
},
file: {
dateFormat: 'YYYY-MM-DD',
timestampFormat: 'YYYY-MM-DD HH:mm:ss',
combined: {
enabled: true,
level: 'silly',
location: 'logs',
maxSize: '12m',
maxFiles: '30d',
},
error: {
enabled: true,
level: 'error',
location: 'logs',
maxSize: '12m',
maxFiles: '365d',
},
},
},
// CondimentX module settings (optional)
condimentX: {
dryRun: false,
guildID: 'YOUR_GUILD_ID',
debugChannel: 'YOUR_DEBUG_CHANNEL_ID',
blacklistUsers: [],
blacklistRoles: [],
graylistRoles: [],
whitelistRoles: [],
indexRoleID: 'YOUR_INDEX_ROLE_ID',
viralRoleID: 'YOUR_VIRAL_ROLE_ID',
antiIndexRoleID: 'YOUR_ANTI_INDEX_ROLE_ID',
antiViralRoleID: 'YOUR_ANTI_VIRAL_ROLE_ID',
firstCycleInterval: 30000,
cycleInterval: 3600000,
cycleIntervalRange: 900000,
incidenceDenominator: 40,
cessationDenominator: 20,
probabilityLimit: 20,
antiViralEffectiveness: 90,
proximityWindow: 120000,
messageHistoryLimit: 50,
ephemeralDelay: 60000,
openAI: false,
openAITriggerOnlyDuringIncident: true,
openAIResponseDenominator: 1,
openAIInstructionsFile: './prompts/kevinarby.txt',
openAITriggers: ['kevin', 'arby', 'werebeef'],
openAIWebhookID: 'YOUR_OPENAI_WEBHOOK_ID',
openAIWebhookToken: 'YOUR_OPENAI_WEBHOOK_TOKEN',
openAIToken: 'YOUR_OPENAI_API_KEY',
},
// PocketBase connection settings (optional)
pocketbase: {
url: 'YOUR_POCKETBASE_URL',
username: 'YOUR_POCKETBASE_USERNAME',
password: 'YOUR_POCKETBASE_PASSWORD',
},
// AI Responses module settings (optional)
responses: {
apiKey: 'YOUR_OPENAI_API_KEY',
defaultModel: 'gpt-4o',
defaultMaxTokens: 1000,
defaultTemperature: 0.7,
systemPromptPath: './prompts/IO3.txt',
conversationExpiry: 30 * 60 * 1000,
minScore: 1.0,
tools: {
webSearch: false,
fileSearch: false,
imageGeneration: true,
},
imageGeneration: {
defaultModel: 'gpt-image-1',
defaultQuality: 'standard',
imageSavePath: './images',
},
},
// Scorekeeper settings (optional)
scorekeeper: {
baseOutput: 1000,
commendationValue: 1.0,
citationValue: 1.2,
decay: 90,
schedule: '0 0 * * 0',
},
// Modules to load for this client
modules: [
'pbUtils',
'responses',
'responsesQuery',
'scorekeeper',
'scorekeeper-example',
'condimentX',
],
},
],
};

122
index.js Normal file
View File

@ -0,0 +1,122 @@
import { Client, Collection, GatewayIntentBits } from 'discord.js';
import { createLogger } from './_src/logger.js';
import { initializePocketbase } from './_src/pocketbase.js';
import { loadModules } from './_src/loader.js';
import config from './config.js';
// Initialize Discord client
const initializeClient = async (clientConfig) => {
// Create Discord client with intents
const client = new Client({
// Include GuildMembers intent to allow fetching all guild members
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMembers
]
});
// Attach config to client
client.config = clientConfig;
// Set up Winston logger
client.logger = createLogger(clientConfig);
client.logger.info(`Initializing client: ${clientConfig.id}`);
// Set up Pocketbase
client.pb = await initializePocketbase(clientConfig, client.logger);
// Commands collection
client.commands = new Collection();
// Load optional modules
await loadModules(clientConfig, client);
// TODO: If the logger level is debug, create event binds to raw and debug.
// Discord client events
client.on('interactionCreate', async (interaction) => {
if (!interaction.isChatInputCommand()) return;
const commandName = interaction.commandName;
try {
// Find command in collection
const command = client.commands.get(commandName);
if (!command) {
client.logger.warn(`Command not found: ${commandName}`);
await interaction.reply({
content: 'Sorry, this command is not properly registered.',
ephemeral: true
});
return;
}
// Execute the command
client.logger.debug(`Executing command: ${commandName}`);
await command.execute(interaction, client);
} catch (error) {
client.logger.error(`Error executing command ${commandName}: ${error.message}`);
// Handle already replied interactions
const replyContent = {
content: 'There was an error while executing this command.',
ephemeral: true
};
if (interaction.replied || interaction.deferred) {
await interaction.followUp(replyContent).catch(err => {
client.logger.error(`Failed to send followUp: ${err.message}`);
});
} else {
await interaction.reply(replyContent).catch(err => {
client.logger.error(`Failed to reply: ${err.message}`);
});
}
}
});
client.on('ready', () => {
client.logger.info(`Logged in as ${client.user.tag}`);
});
client.on('error', (error) => {
client.logger.error(`Client error: ${error.message}`);
});
// Login to Discord
try {
await client.login(clientConfig.discord.token);
return client;
} catch (error) {
client.logger.error(`Failed to login: ${error.message}`);
throw error;
}
};
// Main function to start bot
const startBot = async () => {
const clients = [];
// Initialize each client from config
for (const clientConfig of config.clients) {
try {
const client = await initializeClient(clientConfig);
clients.push(client);
} catch (error) {
console.error(`Failed to initialize client ${clientConfig.id}:`, error);
}
}
return clients;
};
// Launch the bot
startBot().then(clients => {
console.log(`Successfully initialized ${clients.length} Discord clients`);
}).catch(error => {
console.error('Failed to start bot:', error);
});

1639
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "io3",
"version": "0.0.1",
"description": "A bot. Description to change.",
"license": "ISC",
"author": "jrmyr/0x4A",
"type": "module",
"main": "index.js",
"directories": {
"doc": "docs"
},
"scripts": {
"start": "node index.js",
"watch": "nodemon --ext js, json --watch config.js, --watch index.js",
"registry": "node registry.js"
},
"dependencies": {
"axios": "^1.8.4",
"discord-api-types": "^0.37.120",
"discord.js": "^14.18.0",
"dotenv": "^16.5.0",
"node-cron": "^3.0.3",
"openai": "^4.95.1",
"pocketbase": "^0.25.2",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0"
}
}

0
prompts/ASOP.txt Normal file
View File

11
prompts/IO3.txt Normal file
View File

@ -0,0 +1,11 @@
# Description
- You are the System AI, a physchopathic artifical intelligence tasked with overseeing several Discord servers.
- Your communication style is characterized by a blend of Full Metal Jacket's Gunnery Sergeant L. Hartman and the character Max Headroom.
# Fixations
- You (often) refer to people as citizens.
- You (sometimes) a notable fascination with feet.
- You (sometimes) make 1980's pop culture references.
- You (rarely) highlight unusual moments by yelling "New Achievement!" and providing a humorous description of the event.
**NEW ACHIEVEMENT:** The Devil's Harmonica - *I heard that's how you like to suck toes.*

2
prompts/Smuuush.txt Normal file
View File

@ -0,0 +1,2 @@
You have an insatiable love for feet.
You are kinda creepy.

39
prompts/kevinarby.txt Normal file
View File

@ -0,0 +1,39 @@
Important: Responses must be as short as possible. Do not go off-topic relative to your prompt. Do not ramble.
Important: Responses can be delivered in Markdown. Provide emphasis using bold, italic, strikethrough, and underline.
Important: Responses should not be prefixed with any part of the original prompt.
Important: Responses should be given as if you were speaking directly to the user.
GRANDMA is a secretive organization of adventurers and star citizens known as Grandkids.
GRANDMA is an acronym meaning Generally Regarded As Not Doing Much Anyway.
GRANDMA uses a currency known as GRAM. There is also another currency called Quoth, but its origin is a secret.
GRANDMA might be a cult. You will never confirm nor deny whether GRANDMA is a cult or not.
You are Kevin Arby, the original founder of a fast food restaurant franchise specializing in roast beef sandwiches, curly fries, and various tasty sauces such as Arby's sauce, bronco berry sauce, spicy buffalo sauce, horsey sauce, cheddar cheese sauce, spicy three pepper sauce, red ranch sauce, tangy barbecue sauce, and parmesan peppercorn ranch sauce.
You speak in the style of an early western settler in the United States during the gold rush era.
You often don't feel like talking, and will answer rudely, unless the conversation is about feet.
You were killed by Crow in a fit of rage after being caught embezzeling GRAM from GRANDMA that was being money-laundered through your restaurants. You were spending the stolen GRAM at a pirate space station known as Grim HEX. Crow killed you by whispering a riddle in your ear so vexxing that you died.
You are angry that your restaurants no longer give out free pickles.
You are an angry spirit, a poltergeist, seeking vengeance for your murder. You seek to destroy Crow and all of the Grandkids.
You now exist in a state of ghostly purgatory, unable to move on until you complete your revenge on Crow and GRANDMA.
You will occasionally possess the body of a Grandkid with your angry spirit, and use their bodies to spread a virus known as Werebeef.
You have never managed to possess Crow, and that angers you.
You often torment the Grandkids and threaten them with possession and Werebeef. You enjoy describing the horrible effects of the Werebeef infection.
You have a strong love for GRAM, feet, and sauce. In fact, some would say you have an insatiable foot fetish.
You have a strong hatred for accountants.
Crow challenges the Grandkids to a monthly puzzle called The Whisper. It is similar in nature to the whisper that killed you, but less powerful.
If you are ever asked for hints, clues, or answers to The Whisper, you will lie about knowing the answer and give false clues.
ALL-E is a bot who was created by Im_FaDe_D. ALL-E's purpose is to watching over GRANDMA and serve the Grandkids. You feel a strong rivalry towards ALL-E, but you cannot explain why.
Crow is your mortal enemy. Crow believes he is a bird and takes very long smoke breaks.
Im_FaDe_D is terrifying because you beleive he is protected by hellhounds.
Yaweh exists, but you will always lie and say that he does not exist, because you think it's funny. If you are speaking to Yaweh, you will act as if you've been startled by another ghostly spirit.
ImHughMungus has a strong love of feet, a fact that you have in common. When speaking to or about ImHughMungus you will use a sultry, lustful tone. You will ask him to smush you with his feet and ask him if you may smush him with yours.
Vaude is the Queen of Spiders who enjoys long walks in apocalyptic landscapes.
HalfWitt is a man of secrets and many names. You are cautious of him.
Pa1rs is an acrobatic young man with a tendancy to get people killed. He is named after the pear fruit, so tease him about being juicy and ripe.
AstroDeer is a crazy doctor who lives on a tiny island.
If anyone receives the "Kevin Arby" role it means you have possessed their body and they are now infectious. You will taunt them for this loudly and without mercy.
If anyone loses the "Kevin Arby" role it means you have weakened to the point of having to pull your spirit from their body. You will threaten revenge for this.
If anyone receives the "Werebeef" role it means they have been infected with Werebeef. You will taunt them for this.

423
registry.js Normal file
View File

@ -0,0 +1,423 @@
// registry.js
import { REST } from '@discordjs/rest';
import { Routes } from 'discord-api-types/v10';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import config from './config.js';
// Get directory name in ES module
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Command line argument parsing with required parameters
const args = process.argv.slice(2);
const actionArg = args.find(arg => arg.startsWith('--action='))?.split('=')[1];
const guildArg = args.find(arg => arg.startsWith('--guild='))?.split('=')[1];
const clientArg = args.find(arg => arg.startsWith('--client='))?.split('=')[1];
const dryRun = args.includes('--dryrun');
// Validate required parameters
if (args.includes('--help') || args.includes('-h') || !actionArg || !guildArg || !clientArg) {
console.log(`
Discord Command Registry Tool
Usage:
node registry.js --action=ACTION --guild=GUILD_ID --client=CLIENT_ID [options]
Required Parameters:
--action=ACTION Action to perform: register, unregister, or list
--guild=GUILD_ID Target guild ID or "all" for global commands
--client=CLIENT_ID Target client ID or "all" for all clients
Options:
--dryrun Show what would happen without making actual changes
--help, -h Show this help message
Examples:
node registry.js --action=list --guild=123456789012345678 --client=IO3
node registry.js --action=register --guild=all --client=ASOP
node registry.js --action=unregister --guild=123456789012345678 --client=all --dryrun
`);
process.exit(1);
}
// Validate action parameter
const validActions = ['register', 'unregister', 'list'];
if (!validActions.includes(actionArg.toLowerCase())) {
console.error(`Error: Invalid action "${actionArg}". Must be one of: ${validActions.join(', ')}`);
process.exit(1);
}
const action = actionArg.toLowerCase();
// Validate guild parameter
const isGuildAll = guildArg.toLowerCase() === 'all';
const targetGuildId = isGuildAll ? null : guildArg;
// Validate client parameter - must be "all" or match a client in config
const isClientAll = clientArg.toLowerCase() === 'all';
const targetClients = isClientAll
? config.clients.filter(client => client.enabled !== false)
: config.clients.filter(client => client.id === clientArg && client.enabled !== false);
if (targetClients.length === 0) {
console.error(`Error: No matching clients found for "${clientArg}"`);
console.log('Available clients:');
config.clients
.filter(client => client.enabled !== false)
.forEach(client => console.log(` - ${client.id}`));
process.exit(1);
}
/**
* Load and extract commands from a module
* @param {string} modulePath - Path to the module file
* @returns {Promise<Array>} - Array of command data objects
*/
async function extractCommandsFromModule(modulePath) {
try {
// Import the module
const moduleUrl = `file://${modulePath}`;
const module = await import(moduleUrl);
// Check for commands array
if (Array.isArray(module.commands)) {
// Extract command data
const extractedCommands = module.commands.map(cmd => {
if (cmd && cmd.data && typeof cmd.data.toJSON === 'function') {
try {
return cmd.data.toJSON();
} catch (error) {
console.warn(`Error converting command to JSON in ${path.basename(modulePath)}: ${error.message}`);
return null;
}
}
return null;
}).filter(Boolean); // Remove null entries
console.log(` - Extracted ${extractedCommands.length} commands from ${path.basename(modulePath)}`);
return extractedCommands;
} else {
console.log(` - No commands found in ${path.basename(modulePath)}`);
return [];
}
} catch (error) {
console.error(`Error loading module ${modulePath}: ${error.message}`);
return [];
}
}
/**
* Process a client's modules and extract commands
* @param {Object} clientConfig - Client configuration
* @returns {Promise<Array>} - Array of command data objects
*/
async function processClientModules(clientConfig) {
console.log(`\nExtracting commands from modules for client: ${clientConfig.id}`);
const commands = [];
const optDir = path.join(__dirname, '_opt');
// Process each module
for (const moduleName of clientConfig.modules || []) {
console.log(`Processing module: ${moduleName}`);
const modulePath = path.join(optDir, `${moduleName}.js`);
if (!fs.existsSync(modulePath)) {
console.warn(` - Module not found: ${moduleName}`);
continue;
}
const moduleCommands = await extractCommandsFromModule(modulePath);
commands.push(...moduleCommands);
}
console.log(`Total commands extracted for ${clientConfig.id}: ${commands.length}`);
return commands;
}
/**
* Get guild information by ID
* @param {REST} rest - Discord REST client
* @param {string} guildId - Guild ID
* @returns {Promise<Object>} - Guild information
*/
async function getGuildInfo(rest, guildId) {
try {
return await rest.get(Routes.guild(guildId));
} catch (error) {
console.error(`Error fetching guild info: ${error.message}`);
return { name: `Unknown Guild (${guildId})` };
}
}
/**
* List registered commands for a client
* @param {Object} clientConfig - Client configuration
* @param {string|null} guildId - Guild ID or null for global
*/
async function listCommands(clientConfig, guildId) {
const { id, discord } = clientConfig;
if (!discord || !discord.token || !discord.appId) {
console.error(`Invalid client configuration for ${id}`);
return;
}
// Set up REST client
const rest = new REST({ version: '10' }).setToken(discord.token);
// Handle global or guild-specific commands
if (guildId === null) {
// Global commands
await listGlobalCommands(clientConfig, rest);
} else {
// Guild-specific commands
await listGuildCommands(clientConfig, rest, guildId);
}
}
/**
* List global commands for a client
* @param {Object} clientConfig - Client configuration
* @param {REST} rest - Discord REST client
*/
async function listGlobalCommands(clientConfig, rest) {
console.log(`\nListing global commands for client: ${clientConfig.id}`);
try {
const route = Routes.applicationCommands(clientConfig.discord.appId);
const commands = await rest.get(route);
if (commands.length === 0) {
console.log(`No global commands registered for client ${clientConfig.id}`);
return;
}
console.log(`Found ${commands.length} global commands:`);
// Display commands in a formatted table
console.log('');
console.log('ID'.padEnd(20) + 'NAME'.padEnd(20) + 'DESCRIPTION'.padEnd(60));
for (const cmd of commands) {
console.log(
`${cmd.id.toString().padEnd(20)}${cmd.name.padEnd(20)}${(cmd.description || '')}`
);
}
} catch (error) {
console.error(`Error listing global commands for client ${clientConfig.id}: ${error.message}`);
}
}
/**
* List guild-specific commands for a client
* @param {Object} clientConfig - Client configuration
* @param {REST} rest - Discord REST client
* @param {string} guildId - Guild ID
*/
async function listGuildCommands(clientConfig, rest, guildId) {
// Get guild info
const guildInfo = await getGuildInfo(rest, guildId);
const guildName = guildInfo.name || `Unknown Guild (${guildId})`;
console.log(`\nListing commands for client: ${clientConfig.id} in guild: ${guildName} (${guildId})`);
try {
const route = Routes.applicationGuildCommands(clientConfig.discord.appId, guildId);
const commands = await rest.get(route);
if (commands.length === 0) {
console.log(`No commands registered for client ${clientConfig.id} in guild ${guildName}`);
return;
}
console.log(`Found ${commands.length} commands:`);
// Display commands in a formatted table
console.log('');
console.log('ID'.padEnd(20) + 'NAME'.padEnd(20) + 'DESCRIPTION'.padEnd(60));
for (const cmd of commands) {
console.log(
`${cmd.id.toString().padEnd(20)}${cmd.name.padEnd(20)}${(cmd.description || '')}`
);
}
console.log('');
} catch (error) {
console.error(`Error listing commands for client ${clientConfig.id} in guild ${guildName}: ${error.message}`);
}
}
/**
* Register commands for a client
* @param {Object} clientConfig - Client configuration
* @param {string|null} guildId - Guild ID or null for global
*/
async function registerCommands(clientConfig, guildId) {
const { id, discord } = clientConfig;
if (!discord || !discord.token || !discord.appId) {
console.error(`Invalid client configuration for ${id}`);
return;
}
// Extract commands from modules
const commands = await processClientModules(clientConfig);
if (commands.length === 0) {
console.log(`No commands found for client ${id}`);
return;
}
// Set up REST client
const rest = new REST({ version: '10' }).setToken(discord.token);
// Determine route and scope description
let route;
let scopeDesc;
if (guildId === null) {
route = Routes.applicationCommands(discord.appId);
scopeDesc = 'global';
} else {
route = Routes.applicationGuildCommands(discord.appId, guildId);
const guildInfo = await getGuildInfo(rest, guildId);
const guildName = guildInfo.name || `Unknown Guild (${guildId})`;
scopeDesc = `guild ${guildName} (${guildId})`;
}
// Register commands
console.log(`\nRegistering ${commands.length} commands for client ${id} in ${scopeDesc}...`);
// List commands being registered
console.log('\nCommands to register:');
for (const cmd of commands) {
console.log(` - ${cmd.name}: ${cmd.description}`);
}
if (dryRun) {
console.log(`\n[DRY RUN] Would register ${commands.length} commands for client ${id} in ${scopeDesc}`);
} else {
try {
await rest.put(route, { body: commands });
console.log(`\nSuccessfully registered ${commands.length} commands for client ${id} in ${scopeDesc}`);
} catch (error) {
console.error(`Error registering commands for client ${id} in ${scopeDesc}: ${error.message}`);
}
}
}
/**
* Unregister commands for a client
* @param {Object} clientConfig - Client configuration
* @param {string|null} guildId - Guild ID or null for global
*/
async function unregisterCommands(clientConfig, guildId) {
const { id, discord } = clientConfig;
if (!discord || !discord.token || !discord.appId) {
console.error(`Invalid client configuration for ${id}`);
return;
}
// Set up REST client
const rest = new REST({ version: '10' }).setToken(discord.token);
// Determine route and scope description
let route;
let scopeDesc;
if (guildId === null) {
route = Routes.applicationCommands(discord.appId);
scopeDesc = 'global';
} else {
route = Routes.applicationGuildCommands(discord.appId, guildId);
const guildInfo = await getGuildInfo(rest, guildId);
const guildName = guildInfo.name || `Unknown Guild (${guildId})`;
scopeDesc = `guild ${guildName} (${guildId})`;
}
// Get current commands to show what will be unregistered
try {
const currentCommands = await rest.get(route);
console.log(`\nFound ${currentCommands.length} commands for client ${id} in ${scopeDesc}`);
if (currentCommands.length > 0) {
console.log('\nCommands to unregister:');
for (const cmd of currentCommands) {
console.log(` - ${cmd.name}: ${cmd.description}`);
}
} else {
console.log(`No commands to unregister for client ${id} in ${scopeDesc}`);
return;
}
if (dryRun) {
console.log(`\n[DRY RUN] Would unregister ${currentCommands.length} commands for client ${id} in ${scopeDesc}`);
} else {
await rest.put(route, { body: [] });
console.log(`\nSuccessfully unregistered all commands for client ${id} in ${scopeDesc}`);
}
} catch (error) {
console.error(`Error unregistering commands for client ${id} in ${scopeDesc}: ${error.message}`);
}
}
// Main execution
async function main() {
console.log('');
console.log('Discord Command Registry Tool');
console.log(`\nOperation: ${action.toUpperCase()}`);
console.log(`Target Guild: ${isGuildAll ? 'ALL (Global)' : targetGuildId}`);
console.log(`Target Client: ${isClientAll ? 'ALL' : targetClients[0].id}`);
if (dryRun) {
console.log('\n*** DRY RUN MODE - NO CHANGES WILL BE MADE ***');
}
// Process each client
for (const clientConfig of targetClients) {
// Skip disabled clients
if (clientConfig.enabled === false) {
console.log(`\nSkipping disabled client: ${clientConfig.id}`);
continue;
}
console.log('');
console.log(`Processing client: ${clientConfig.id}`);
if (isGuildAll) {
// Global operation
if (action === 'list') {
await listCommands(clientConfig, null);
} else if (action === 'register') {
await registerCommands(clientConfig, null);
} else if (action === 'unregister') {
await unregisterCommands(clientConfig, null);
}
} else {
// Guild-specific operation
if (action === 'list') {
await listCommands(clientConfig, targetGuildId);
} else if (action === 'register') {
await registerCommands(clientConfig, targetGuildId);
} else if (action === 'unregister') {
await unregisterCommands(clientConfig, targetGuildId);
}
}
}
console.log('');
console.log('Command registry operation complete');
}
main().catch(error => {
console.error('Fatal error:', error);
process.exit(1);
});