diff --git a/biome.json b/biome.json index fd9864302..559da52c8 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.5/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json", "files": { "includes": [ "src/**/*.js", diff --git a/config.json b/config.json index af13a1353..0ea9fad98 100644 --- a/config.json +++ b/config.json @@ -150,6 +150,28 @@ "flushIntervalMs": 5000 } }, + "botStatus": { + "enabled": true, + "status": "online", + "rotation": { + "enabled": true, + "intervalMinutes": 5, + "messages": [ + { + "type": "Watching", + "text": "{guildCount} servers" + }, + { + "type": "Listening", + "text": "to {memberCount} members" + }, + { + "type": "Playing", + "text": "with /help" + } + ] + } + }, "permissions": { "enabled": true, "adminRoleIds": [], diff --git a/src/api/routes/guilds.js b/src/api/routes/guilds.js index 9487e4110..6235ce961 100644 --- a/src/api/routes/guilds.js +++ b/src/api/routes/guilds.js @@ -740,14 +740,25 @@ router.patch('/:id/config', requireGuildAdmin, validateGuild, async (req, res) = } const { path, value, topLevelKey } = result; + // botStatus is global (not per-guild) — only bot owners may write to it. + const isGlobalBotStatusWrite = topLevelKey === 'botStatus'; + if (isGlobalBotStatusWrite && req.authMethod === 'oauth' && !isOAuthBotOwner(req.user)) { + return res.status(403).json({ error: 'Only bot owners can update global bot status' }); + } + const writeScope = isGlobalBotStatusWrite ? 'global' : req.params.id; try { - await setConfigValue(path, value, req.params.id); - const effectiveConfig = getConfig(req.params.id); + await setConfigValue(path, value, writeScope === 'global' ? undefined : req.params.id); + const effectiveConfig = writeScope === 'global' ? getConfig() : getConfig(req.params.id); const effectiveSection = effectiveConfig[topLevelKey] || {}; const sensitivePattern = /key|secret|token|password/i; const logValue = sensitivePattern.test(path) ? '[REDACTED]' : value; - info('Config updated via API', { path, value: logValue, guild: req.params.id }); + info('Config updated via API', { + path, + value: logValue, + guild: req.params.id, + scope: writeScope, + }); fireAndForgetWebhook('DASHBOARD_WEBHOOK_URL', { event: 'config.updated', guildId: req.params.id, diff --git a/src/api/utils/configValidation.js b/src/api/utils/configValidation.js index 402587313..9191594de 100644 --- a/src/api/utils/configValidation.js +++ b/src/api/utils/configValidation.js @@ -177,6 +177,40 @@ export const CONFIG_SCHEMA = { retentionDays: { type: 'number', min: 1, max: 365 }, }, }, + botStatus: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + status: { type: 'string', enum: ['online', 'idle', 'dnd', 'invisible'] }, + activityType: { + type: 'string', + enum: ['Playing', 'Watching', 'Listening', 'Competing', 'Streaming', 'Custom'], + }, + activities: { type: 'array', items: { type: 'string' } }, + rotateIntervalMs: { type: 'number' }, + rotation: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + intervalMinutes: { type: 'number' }, + messages: { + type: 'array', + items: { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['Playing', 'Watching', 'Listening', 'Competing', 'Streaming', 'Custom'], + }, + text: { type: 'string', minLength: 1, pattern: '\\S' }, + }, + required: ['text'], + }, + }, + }, + }, + }, + }, reminders: { type: 'object', properties: { @@ -251,12 +285,18 @@ export function validateValue(value, schema, path) { if (typeof value !== 'string') { errors.push(`${path}: expected string, got ${typeof value}`); } else { + if (typeof schema.minLength === 'number' && value.length < schema.minLength) { + errors.push(`${path}: must be at least ${schema.minLength} characters`); + } if (schema.enum && !schema.enum.includes(value)) { errors.push(`${path}: must be one of [${schema.enum.join(', ')}], got "${value}"`); } if (schema.maxLength != null && value.length > schema.maxLength) { errors.push(`${path}: exceeds max length of ${schema.maxLength}`); } + if (schema.pattern && !new RegExp(schema.pattern).test(value)) { + errors.push(`${path}: does not match required pattern`); + } } break; case 'number': @@ -276,24 +316,7 @@ export function validateValue(value, schema, path) { errors.push(`${path}: expected array, got ${typeof value}`); } else if (schema.items) { for (let i = 0; i < value.length; i++) { - const item = value[i]; - if (schema.items.type === 'string') { - if (typeof item !== 'string') { - errors.push(`${path}[${i}]: expected string, got ${typeof item}`); - } - } else if (schema.items.type === 'object') { - if (typeof item !== 'object' || item === null || Array.isArray(item)) { - errors.push( - `${path}[${i}]: expected object, got ${Array.isArray(item) ? 'array' : item === null ? 'null' : typeof item}`, - ); - } else if (schema.items.required) { - for (const key of schema.items.required) { - if (!(key in item)) { - errors.push(`${path}[${i}]: missing required key "${key}"`); - } - } - } - } + errors.push(...validateValue(value[i], schema.items, `${path}[${i}]`)); } } break; @@ -302,14 +325,24 @@ export function validateValue(value, schema, path) { errors.push( `${path}: expected object, got ${Array.isArray(value) ? 'array' : typeof value}`, ); - } else if (schema.properties) { - for (const [key, val] of Object.entries(value)) { - if (Object.hasOwn(schema.properties, key)) { - errors.push(...validateValue(val, schema.properties[key], `${path}.${key}`)); - } else if (!schema.openProperties) { - errors.push(`${path}.${key}: unknown config key`); + } else { + if (schema.required) { + for (const key of schema.required) { + if (!Object.hasOwn(value, key)) { + errors.push(`${path}: missing required key "${key}"`); + } + } + } + + if (schema.properties) { + for (const [key, val] of Object.entries(value)) { + if (Object.hasOwn(schema.properties, key)) { + errors.push(...validateValue(val, schema.properties[key], `${path}.${key}`)); + } else if (!schema.openProperties) { + errors.push(`${path}.${key}: unknown config key`); + } + // openProperties: true — freeform map, unknown keys are allowed } - // openProperties: true — freeform map, unknown keys are allowed } } break; diff --git a/src/config-listeners.js b/src/config-listeners.js index d519f1a3b..5496c4510 100644 --- a/src/config-listeners.js +++ b/src/config-listeners.js @@ -113,6 +113,10 @@ export function registerConfigListeners({ dbPool, config }) { 'botStatus.activityType', 'botStatus.activities', 'botStatus.rotateIntervalMs', + 'botStatus.rotation', + 'botStatus.rotation.enabled', + 'botStatus.rotation.intervalMinutes', + 'botStatus.rotation.messages', ]) { onConfigChange(key, (_newValue, _oldValue, _path, guildId) => { // Bot presence is global — ignore per-guild overrides here diff --git a/src/index.js b/src/index.js index 6f7129bcd..4b88beac3 100644 --- a/src/index.js +++ b/src/index.js @@ -42,6 +42,7 @@ import { startConversationCleanup, stopConversationCleanup, } from './modules/ai.js'; +import { startBotStatus, stopBotStatus } from './modules/botStatus.js'; import { loadConfig } from './modules/config.js'; import { registerEventHandlers } from './modules/events.js'; @@ -193,6 +194,7 @@ async function gracefulShutdown(signal) { stopWarningExpiryScheduler(); stopScheduler(); stopGithubFeed(); + stopBotStatus(); // 1.5. Stop API server (drain in-flight HTTP requests before closing DB) try { @@ -379,6 +381,9 @@ async function startup() { await loadCommands(); await client.login(token); + // Start configurable bot presence rotation after login so client.user is available + startBotStatus(client); + // Set Sentry context now that we know the bot identity (no-op if disabled) import('./sentry.js') .then(({ Sentry, sentryEnabled }) => { diff --git a/src/modules/botStatus.js b/src/modules/botStatus.js index e6662f4e2..06eb2db23 100644 --- a/src/modules/botStatus.js +++ b/src/modules/botStatus.js @@ -1,47 +1,65 @@ /** * Bot Status Module - * Manages configurable bot presence: status and activity messages. + * Manages configurable bot presence with optional rotation and template variables. * - * Features: - * - Configurable status (online, idle, dnd, invisible) - * - Custom activity text with variable interpolation - * - Rotating activities (cycles through a list on configurable interval) - * - * Config shape (config.botStatus): + * Supported config formats: + * 1) New format: * { * enabled: true, - * status: "online", // online | idle | dnd | invisible - * activityType: "Playing", // Playing | Watching | Listening | Competing | Streaming | Custom - * activities: [ // Rotated in order; single entry = static - * "with {memberCount} members", - * "in {guildCount} servers" - * ], - * rotateIntervalMs: 30000 // How often to rotate (ms), default 30s + * status: 'online', + * rotation: { + * enabled: true, + * intervalMinutes: 5, + * messages: [ + * { type: 'Watching', text: '{guildCount} servers' }, + * { type: 'Playing', text: 'with /help' } + * ] + * } * } * - * Variables available in activity text: - * {memberCount} Total member count across all guilds - * {guildCount} Number of guilds the bot is in - * {botName} The bot's username + * 2) Legacy format (kept for compatibility): + * { + * enabled: true, + * status: 'online', + * activityType: 'Playing', + * activities: ['with Discord', 'in {guildCount} servers'], + * rotateIntervalMs: 30000 + * } */ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { ActivityType } from 'discord.js'; import { info, warn } from '../logger.js'; import { getConfig } from './config.js'; -/** Map Discord activity type strings to ActivityType enum values */ +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** + * Map Discord activity type strings to ActivityType enum values. + * + * Note: Streaming is intentionally excluded — Discord requires a valid Twitch/YouTube + * `url` for Streaming activities, but the config schema has no URL field. Without a URL + * it silently renders as "Playing." Re-add Streaming here once URL support is added. + */ const ACTIVITY_TYPE_MAP = { Playing: ActivityType.Playing, Watching: ActivityType.Watching, Listening: ActivityType.Listening, Competing: ActivityType.Competing, - Streaming: ActivityType.Streaming, Custom: ActivityType.Custom, }; /** Valid Discord presence status strings */ const VALID_STATUSES = new Set(['online', 'idle', 'dnd', 'invisible']); +const DEFAULT_LEGACY_ROTATE_INTERVAL_MS = 30_000; +const DEFAULT_ROTATE_INTERVAL_MINUTES = 5; +const MIN_PRESENCE_INTERVAL_MS = 20_000; +const DEFAULT_ACTIVITY_TYPE = 'Playing'; +const DEFAULT_ACTIVITY_TEXT = 'with Discord'; + /** @type {ReturnType | null} */ let rotateInterval = null; @@ -51,6 +69,77 @@ let currentActivityIndex = 0; /** @type {import('discord.js').Client | null} */ let _client = null; +/** @type {string | null} */ +let cachedVersion = null; + +/** + * Format milliseconds into a compact uptime string (e.g. '2d 3h 15m'). + * + * @param {number} uptimeMs + * @returns {string} + */ +export function formatUptime(uptimeMs) { + if (!Number.isFinite(uptimeMs) || uptimeMs <= 0) return '0m'; + + const totalMinutes = Math.floor(uptimeMs / 60_000); + const days = Math.floor(totalMinutes / (24 * 60)); + const hours = Math.floor((totalMinutes % (24 * 60)) / 60); + const minutes = totalMinutes % 60; + + const parts = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + parts.push(`${minutes}m`); + return parts.join(' '); +} + +/** + * Resolve package version from root package.json. + * + * @returns {string} + */ +function getPackageVersion() { + if (cachedVersion) return cachedVersion; + try { + const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8')); + cachedVersion = typeof pkg?.version === 'string' ? pkg.version : 'unknown'; + } catch { + cachedVersion = 'unknown'; + } + return cachedVersion; +} + +/** + * Check whether an activity type string is supported by Discord presence handling. + * + * @param {string | undefined} typeStr + * @returns {boolean} + */ +function isSupportedActivityType(typeStr) { + return typeof typeStr === 'string' && Object.hasOwn(ACTIVITY_TYPE_MAP, typeStr); +} + +/** + * Resolve a fallback activity type string for normalization paths. + * + * @param {string | undefined} typeStr + * @param {string} source + * @returns {string} + */ +function resolveFallbackType(typeStr, source) { + if (!typeStr) return DEFAULT_ACTIVITY_TYPE; + + if (isSupportedActivityType(typeStr)) { + return typeStr; + } + + warn('Invalid bot status activity type, falling back to Playing', { + source, + invalidType: typeStr, + }); + return DEFAULT_ACTIVITY_TYPE; +} + /** * Interpolate variables in an activity text string. * @@ -64,42 +153,230 @@ export function interpolateActivity(text, client) { const memberCount = client.guilds?.cache?.reduce((sum, g) => sum + (g.memberCount ?? 0), 0) ?? 0; const guildCount = client.guilds?.cache?.size ?? 0; const botName = client.user?.username ?? 'Bot'; + const commandCount = client.commands?.size ?? 0; + const uptime = formatUptime(client.uptime ?? 0); + const version = getPackageVersion(); return text - .replace(/\{memberCount\}/g, String(memberCount)) - .replace(/\{guildCount\}/g, String(guildCount)) - .replace(/\{botName\}/g, botName); + .replaceAll('{memberCount}', String(memberCount)) + .replaceAll('{guildCount}', String(guildCount)) + .replaceAll('{botName}', botName) + .replaceAll('{commandCount}', String(commandCount)) + .replaceAll('{uptime}', uptime) + .replaceAll('{version}', version); } /** - * Resolve status and activity type from config with safe fallbacks. + * Resolve the configured global online status with safe fallback. * - * @param {Object} cfg - botStatus config section - * @returns {{ status: string, activityType: ActivityType }} Resolved values + * @param {Object} cfg + * @returns {string} + */ +export function resolvePresenceStatus(cfg) { + return VALID_STATUSES.has(cfg?.status) ? cfg.status : 'online'; +} + +/** + * Resolve a configured activity type string into Discord enum. + * + * @param {string | undefined} typeStr + * @returns {ActivityType} + */ +export function resolveActivityType(typeStr) { + if (!typeStr) return ActivityType.Playing; + if (isSupportedActivityType(typeStr)) { + return ACTIVITY_TYPE_MAP[typeStr]; + } + + warn('Invalid bot status activity type, falling back to Playing', { + source: 'botStatus', + invalidType: typeStr, + }); + return ActivityType.Playing; +} + +/** + * Legacy helper kept for backward compatibility with existing call sites/tests. + * + * @param {Object} cfg + * @returns {{ status: string, activityType: ActivityType }} */ export function resolvePresenceConfig(cfg) { - const status = VALID_STATUSES.has(cfg?.status) ? cfg.status : 'online'; + return { + status: resolvePresenceStatus(cfg), + activityType: resolveActivityType(cfg?.activityType), + }; +} + +/** + * Determine the type label for an entry for logging purposes. + * @param {unknown} entry + * @returns {string} + */ +function getEntryTypeLabel(entry) { + if (Array.isArray(entry)) return 'array'; + if (entry === null) return 'null'; + return typeof entry; +} + +/** + * Normalize a configured status message entry. + * + * @param {unknown} entry + * @param {string | undefined} fallbackType + * @param {string} source + * @returns {{type: string, text: string} | null} + */ +function normalizeMessage(entry, fallbackType, source) { + const resolvedFallbackType = resolveFallbackType(fallbackType, `${source}.fallbackType`); + + if (typeof entry === 'string') { + const text = entry.trim(); + if (!text) { + warn('Ignoring empty bot status message entry', { source }); + return null; + } + return { type: resolvedFallbackType, text }; + } + + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + warn('Ignoring invalid bot status message entry', { + source, + entryType: getEntryTypeLabel(entry), + }); + return null; + } + + const rawText = typeof entry.text === 'string' ? entry.text.trim() : ''; + if (!rawText) { + warn('Ignoring bot status message without valid text', { source }); + return null; + } - const typeStr = cfg?.activityType ?? 'Playing'; - const activityType = - ACTIVITY_TYPE_MAP[typeStr] !== undefined ? ACTIVITY_TYPE_MAP[typeStr] : ActivityType.Playing; + let type = resolvedFallbackType; + if (typeof entry.type === 'string' && entry.type.trim()) { + if (isSupportedActivityType(entry.type)) { + type = entry.type; + } else { + warn('Invalid bot status message type, falling back to configured/default type', { + source, + invalidType: entry.type, + fallbackType: resolvedFallbackType, + }); + } + } - return { status, activityType }; + return { type, text: rawText }; } /** - * Get the active activities list from config. - * Falls back to a sensible default if none configured. + * Return normalized rotation messages from new or legacy config fields. * * @param {Object} cfg - botStatus config section - * @returns {string[]} Non-empty array of activity strings + * @returns {{type: string, text: string}[]} + */ +export function getRotationMessages(cfg) { + const rotationMessages = cfg?.rotation?.messages; + if (Array.isArray(rotationMessages)) { + const normalized = rotationMessages + .map((entry, index) => + normalizeMessage(entry, cfg?.activityType, `botStatus.rotation.messages[${index}]`), + ) + .filter((entry) => entry !== null); + if (normalized.length > 0) { + return normalized; + } + + if (rotationMessages.length > 0) { + warn('Configured botStatus.rotation.messages had no usable entries; falling back', { + fallback: + Array.isArray(cfg?.activities) && cfg.activities.length > 0 + ? 'botStatus.activities' + : 'default', + }); + } + } + + const legacyActivities = cfg?.activities; + if (Array.isArray(legacyActivities)) { + const normalized = legacyActivities + .map((entry, index) => + normalizeMessage(entry, cfg?.activityType, `botStatus.activities[${index}]`), + ) + .filter((entry) => entry !== null); + if (normalized.length > 0) { + return normalized; + } + + if (legacyActivities.length > 0) { + warn('Configured botStatus.activities had no usable entries; using default activity', {}); + } + } + + return [{ type: DEFAULT_ACTIVITY_TYPE, text: DEFAULT_ACTIVITY_TEXT }]; +} + +/** + * Legacy helper kept for backward compatibility with existing call sites/tests. + * + * @param {Object} cfg + * @returns {string[]} */ export function getActivities(cfg) { - const list = cfg?.activities; - if (Array.isArray(list) && list.length > 0) { - return list.filter((a) => typeof a === 'string' && a.trim().length > 0); + return getRotationMessages(cfg).map((entry) => entry.text); +} + +/** + * Resolve rotation interval in milliseconds with Discord-safe minimum. + * + * @param {Object} cfg - botStatus config section + * @returns {number} + */ +export function resolveRotationIntervalMs(cfg) { + if (typeof cfg?.rotation?.intervalMinutes === 'number' && cfg.rotation.intervalMinutes > 0) { + return Math.max(Math.round(cfg.rotation.intervalMinutes * 60_000), MIN_PRESENCE_INTERVAL_MS); } - return ['with Discord']; + + // New-format fallback must come before legacy — prevents cross-format leakage + // when intervalMinutes is 0 or negative but rotation key exists. + if (cfg?.rotation) { + return DEFAULT_ROTATE_INTERVAL_MINUTES * 60_000; + } + + if (typeof cfg?.rotateIntervalMs === 'number' && cfg.rotateIntervalMs > 0) { + return Math.max(cfg.rotateIntervalMs, MIN_PRESENCE_INTERVAL_MS); + } + + return DEFAULT_LEGACY_ROTATE_INTERVAL_MS; +} + +/** + * Determine whether rotation should be active. + * New format obeys rotation.enabled. Legacy format rotates when multiple activities exist. + * + * @param {Object} cfg - botStatus config section + * @param {number} messageCount + * @returns {boolean} + */ +export function isRotationEnabled(cfg, messageCount) { + if (cfg?.rotation && typeof cfg.rotation.enabled === 'boolean') { + return cfg.rotation.enabled && messageCount > 1; + } + return messageCount > 1; +} + +/** + * Build Discord activity payload for presence update. + * + * @param {string} text + * @param {ActivityType} type + * @returns {{name: string, type: ActivityType, state?: string}} + */ +function buildActivityPayload(text, type) { + if (type === ActivityType.Custom) { + return { name: 'Custom Status', state: text, type }; + } + return { name: text, type }; } /** @@ -108,32 +385,30 @@ export function getActivities(cfg) { * @param {import('discord.js').Client} client - Discord client */ export function applyPresence(client) { - const globalCfg = getConfig(); - const cfg = globalCfg?.botStatus; - - if (!cfg?.enabled) return; + const cfg = getConfig()?.botStatus; - const { status, activityType } = resolvePresenceConfig(cfg); - const activities = getActivities(cfg); + if (!cfg?.enabled || !client?.user) return; - // Guard against empty list after filter - if (activities.length === 0) return; + const status = resolvePresenceStatus(cfg); + const messages = getRotationMessages(cfg); + if (messages.length === 0) return; - // Clamp index to list length - currentActivityIndex = currentActivityIndex % activities.length; - const rawText = activities[currentActivityIndex]; - const name = interpolateActivity(rawText, client); + currentActivityIndex = currentActivityIndex % messages.length; + const activeMessage = messages[currentActivityIndex]; + const activityType = resolveActivityType(activeMessage.type); + const text = interpolateActivity(activeMessage.text, client); + const activity = buildActivityPayload(text, activityType); try { client.user.setPresence({ status, - activities: [{ name, type: activityType }], + activities: [activity], }); info('Bot presence updated', { status, - activityType: cfg.activityType ?? 'Playing', - activity: name, + activityType: activeMessage.type, + activity: text, index: currentActivityIndex, }); } catch (err) { @@ -147,9 +422,7 @@ export function applyPresence(client) { * @param {import('discord.js').Client} client - Discord client */ function rotate(client) { - const cfg = getConfig()?.botStatus; - const activities = getActivities(cfg); - currentActivityIndex = (currentActivityIndex + 1) % Math.max(activities.length, 1); + currentActivityIndex += 1; applyPresence(client); } @@ -160,36 +433,35 @@ function rotate(client) { * @param {import('discord.js').Client} client - Discord client */ export function startBotStatus(client) { + if (rotateInterval) { + clearInterval(rotateInterval); + rotateInterval = null; + } _client = client; const cfg = getConfig()?.botStatus; if (!cfg?.enabled) { - info('Bot status module disabled — skipping'); + info('Bot status module disabled - skipping'); return; } - // Apply immediately currentActivityIndex = 0; applyPresence(client); - const activities = getActivities(cfg); - const intervalMs = - typeof cfg.rotateIntervalMs === 'number' && cfg.rotateIntervalMs > 0 - ? cfg.rotateIntervalMs - : 30_000; - - // Only start rotation interval if there are multiple activities to rotate through - if (activities.length > 1) { - rotateInterval = setInterval(() => rotate(client), intervalMs); - info('Bot status rotation started', { - activitiesCount: activities.length, - intervalMs, - }); - } else { - info('Bot status set (single activity — no rotation)', { - activity: activities[0], + const messages = getRotationMessages(cfg); + if (!isRotationEnabled(cfg, messages.length)) { + info('Bot status set (rotation disabled or single message)', { + activity: messages[0]?.text ?? '', }); + return; } + + const intervalMs = resolveRotationIntervalMs(cfg); + rotateInterval = setInterval(() => rotate(client), intervalMs); + info('Bot status rotation started', { + messagesCount: messages.length, + intervalMs, + }); } /** @@ -205,16 +477,28 @@ export function stopBotStatus() { } /** - * Reload bot status — called when config changes. + * Reload bot status - called when config changes. * Stops any running rotation and restarts with new config. + * If the module is now disabled, clears Discord presence so the last activity + * doesn't remain stuck. * * @param {import('discord.js').Client} [client] - Discord client (uses cached if omitted) */ export function reloadBotStatus(client) { - // Capture cached client BEFORE stopBotStatus() nulls it out const target = client ?? _client; stopBotStatus(); if (target) { + const cfg = getConfig()?.botStatus; + if (!cfg?.enabled) { + // Clear Discord presence so the last activity doesn't remain displayed + try { + target.user?.setPresence({ activities: [], status: 'online' }); + info('Bot presence cleared (module disabled)'); + } catch (err) { + warn('Failed to clear bot presence', { error: err.message }); + } + return; + } startBotStatus(target); } } diff --git a/src/modules/events.js b/src/modules/events.js index ae2ba6d92..0fd73ca94 100644 --- a/src/modules/events.js +++ b/src/modules/events.js @@ -11,8 +11,8 @@ import { registerCommandInteractionHandler } from './events/commandInteraction.j import { registerErrorHandlers } from './events/errors.js'; import { registerGuildMemberAddHandler } from './events/guildMemberAdd.js'; import { - registerComponentHandlers, registerChallengeButtonHandler, + registerComponentHandlers, registerPollButtonHandler, registerReminderButtonHandler, registerReviewClaimHandler, @@ -30,18 +30,17 @@ import { registerVoiceStateHandler } from './events/voiceState.js'; // Re-export for backward compatibility export { + // Deprecated — use handler functions directly + registerChallengeButtonHandler, registerClientReadyHandler, registerCommandInteractionHandler, - registerReadyHandler, - registerMessageCreateHandler, - registerGuildMemberAddHandler, registerComponentHandlers, - registerReactionHandlers, registerErrorHandlers, - registerVoiceStateHandler, - // Deprecated — use handler functions directly - registerChallengeButtonHandler, + registerGuildMemberAddHandler, + registerMessageCreateHandler, registerPollButtonHandler, + registerReactionHandlers, + registerReadyHandler, registerReminderButtonHandler, registerReviewClaimHandler, registerShowcaseButtonHandler, @@ -49,6 +48,7 @@ export { registerTicketCloseButtonHandler, registerTicketModalHandler, registerTicketOpenButtonHandler, + registerVoiceStateHandler, registerWelcomeOnboardingHandlers, }; diff --git a/src/modules/events/interactionCreate.js b/src/modules/events/interactionCreate.js index 357fc775e..3d04f8acb 100644 --- a/src/modules/events/interactionCreate.js +++ b/src/modules/events/interactionCreate.js @@ -17,14 +17,20 @@ import { handleTicketModal, handleTicketOpenButton, } from '../handlers/ticketHandler.js'; -import { handleWelcomeOnboarding, registerWelcomeOnboardingHandlers } from '../handlers/welcomeOnboardingHandler.js'; +import { + handleWelcomeOnboarding, + registerWelcomeOnboardingHandlers, +} from '../handlers/welcomeOnboardingHandler.js'; // Backward-compatible re-exports (deprecated — use handler functions directly) export { registerChallengeButtonHandler } from '../handlers/challengeHandler.js'; export { registerPollButtonHandler } from '../handlers/pollHandler.js'; export { registerReminderButtonHandler } from '../handlers/reminderHandler.js'; export { registerReviewClaimHandler } from '../handlers/reviewHandler.js'; -export { registerShowcaseButtonHandler, registerShowcaseModalHandler } from '../handlers/showcaseHandler.js'; +export { + registerShowcaseButtonHandler, + registerShowcaseModalHandler, +} from '../handlers/showcaseHandler.js'; export { registerTicketCloseButtonHandler, registerTicketModalHandler, diff --git a/src/modules/events/messageCreate.js b/src/modules/events/messageCreate.js index b12127c5f..0fed0243f 100644 --- a/src/modules/events/messageCreate.js +++ b/src/modules/events/messageCreate.js @@ -17,8 +17,8 @@ import { handleQuietCommand, isQuietMode } from '../quietMode.js'; import { checkRateLimit } from '../rateLimit.js'; import { handleXpGain } from '../reputation.js'; import { isSpam, sendSpamAlert } from '../spam.js'; -import { clearChannelState } from '../triage-buffer.js'; import { accumulateMessage, evaluateNow } from '../triage.js'; +import { clearChannelState } from '../triage-buffer.js'; import { recordCommunityActivity } from '../welcome.js'; /** diff --git a/src/modules/handlers/ticketHandler.js b/src/modules/handlers/ticketHandler.js index 233193f79..43916e30c 100644 --- a/src/modules/handlers/ticketHandler.js +++ b/src/modules/handlers/ticketHandler.js @@ -38,9 +38,7 @@ export async function handleTicketOpenButton(interaction) { return true; } - const modal = new ModalBuilder() - .setCustomId('ticket_open_modal') - .setTitle('Open Support Ticket'); + const modal = new ModalBuilder().setCustomId('ticket_open_modal').setTitle('Open Support Ticket'); const topicInput = new TextInputBuilder() .setCustomId('ticket_topic') diff --git a/src/modules/performanceMonitor.js b/src/modules/performanceMonitor.js index 79d243085..7e2db3b08 100644 --- a/src/modules/performanceMonitor.js +++ b/src/modules/performanceMonitor.js @@ -269,4 +269,4 @@ class PerformanceMonitor { PerformanceMonitor.instance = null; -export { PerformanceMonitor, CircularBuffer, DEFAULT_THRESHOLDS, SAMPLE_INTERVAL_MS }; +export { CircularBuffer, DEFAULT_THRESHOLDS, PerformanceMonitor, SAMPLE_INTERVAL_MS }; diff --git a/tests/api/routes/guilds.test.js b/tests/api/routes/guilds.test.js index e68b88549..d2dbb6519 100644 --- a/tests/api/routes/guilds.test.js +++ b/tests/api/routes/guilds.test.js @@ -467,6 +467,35 @@ describe('guilds routes', () => { expect(getConfig).toHaveBeenCalledWith('guild1'); }); + it('should route botStatus writes to global config', async () => { + getConfig.mockReturnValueOnce({}); + getConfig.mockReturnValueOnce({ + botStatus: { + enabled: true, + status: 'idle', + }, + }); + getConfig.mockReturnValueOnce({ + botStatus: { + enabled: true, + status: 'idle', + }, + }); + + const res = await request(app) + .patch('/api/v1/guilds/guild1/config') + .set('x-api-secret', SECRET) + .send({ path: 'botStatus.status', value: 'idle' }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + enabled: true, + status: 'idle', + }); + expect(setConfigValue).toHaveBeenCalledWith('botStatus.status', 'idle', undefined); + expect(getConfig.mock.calls.some((call) => call.length === 0)).toBe(true); + }); + it('should return 400 when request body is missing', async () => { const res = await request(app) .patch('/api/v1/guilds/guild1/config') diff --git a/tests/api/routes/warnings.test.js b/tests/api/routes/warnings.test.js index 0a4546393..9e47daa35 100644 --- a/tests/api/routes/warnings.test.js +++ b/tests/api/routes/warnings.test.js @@ -67,7 +67,9 @@ describe('warnings routes', () => { } expect(sql).toContain('SELECT * FROM warnings'); - expect(sql).toContain('WHERE guild_id = $1 AND user_id = $2 AND active = $3 AND severity = $4'); + expect(sql).toContain( + 'WHERE guild_id = $1 AND user_id = $2 AND active = $3 AND severity = $4', + ); expect(params).toEqual(['guild-1', 'user-1', true, 'high', 10, 10]); return { rows: [{ id: 1, user_id: 'user-1', severity: 'high' }] }; }); diff --git a/tests/api/utils/configAllowlist.test.js b/tests/api/utils/configAllowlist.test.js index 183333f17..78ec4b8a3 100644 --- a/tests/api/utils/configAllowlist.test.js +++ b/tests/api/utils/configAllowlist.test.js @@ -20,6 +20,7 @@ describe('configAllowlist', () => { expect(SAFE_CONFIG_KEYS.has('starboard')).toBe(true); expect(SAFE_CONFIG_KEYS.has('permissions')).toBe(true); expect(SAFE_CONFIG_KEYS.has('memory')).toBe(true); + expect(SAFE_CONFIG_KEYS.has('botStatus')).toBe(true); }); }); @@ -35,6 +36,7 @@ describe('configAllowlist', () => { expect(READABLE_CONFIG_KEYS).toContain('memory'); expect(READABLE_CONFIG_KEYS).toContain('permissions'); expect(READABLE_CONFIG_KEYS).toContain('starboard'); + expect(READABLE_CONFIG_KEYS).toContain('botStatus'); }); }); diff --git a/tests/api/utils/configValidation.test.js b/tests/api/utils/configValidation.test.js index b62c457e2..227c1ad74 100644 --- a/tests/api/utils/configValidation.test.js +++ b/tests/api/utils/configValidation.test.js @@ -36,6 +36,31 @@ describe('configValidation', () => { expect(errors).toHaveLength(1); expect(errors[0]).toContain('unknown config key'); }); + + it('should validate nested object arrays using item properties', () => { + const schema = { + type: 'array', + items: { + type: 'object', + properties: { + type: { type: 'string', enum: ['Playing', 'Watching'] }, + text: { type: 'string', minLength: 1 }, + }, + required: ['text'], + }, + }; + + expect(validateValue([{ type: 'Watching', text: 'ready' }], schema, 'test')).toEqual([]); + expect(validateValue([{ type: 'Bad', text: 'ready' }], schema, 'test')[0]).toContain( + 'must be one of', + ); + expect(validateValue([{ type: 'Watching', text: '' }], schema, 'test')[0]).toContain( + 'at least 1 characters', + ); + expect( + validateValue([{ type: 'Watching', text: 'ready', extra: true }], schema, 'test')[0], + ).toContain('unknown config key'); + }); }); describe('validateSingleValue', () => { @@ -104,7 +129,15 @@ describe('configValidation', () => { describe('CONFIG_SCHEMA', () => { it('should have schemas for all expected top-level sections', () => { expect(Object.keys(CONFIG_SCHEMA)).toEqual( - expect.arrayContaining(['ai', 'welcome', 'spam', 'moderation', 'triage', 'auditLog']), + expect.arrayContaining([ + 'ai', + 'welcome', + 'spam', + 'moderation', + 'triage', + 'auditLog', + 'botStatus', + ]), ); }); }); @@ -217,6 +250,52 @@ describe('configValidation', () => { }); }); + describe('botStatus schema validation', () => { + it('should accept valid botStatus rotation settings', () => { + expect(validateSingleValue('botStatus.enabled', true)).toEqual([]); + expect(validateSingleValue('botStatus.status', 'online')).toEqual([]); + expect(validateSingleValue('botStatus.rotation.enabled', false)).toEqual([]); + expect(validateSingleValue('botStatus.rotation.intervalMinutes', 5)).toEqual([]); + expect( + validateSingleValue('botStatus.rotation.messages', [ + { type: 'Watching', text: '{guildCount} servers' }, + ]), + ).toEqual([]); + }); + + it('should reject invalid botStatus status value', () => { + const errors = validateSingleValue('botStatus.status', 'busy'); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('must be one of'); + }); + + it('should reject rotation messages missing text', () => { + const errors = validateSingleValue('botStatus.rotation.messages', [{ type: 'Watching' }]); + expect(errors.some((e) => e.includes('missing required key "text"'))).toBe(true); + }); + + it('should reject rotation messages with invalid type', () => { + const errors = validateSingleValue('botStatus.rotation.messages', [ + { type: 'Dancing', text: '{guildCount} servers' }, + ]); + expect(errors.some((e) => e.includes('must be one of'))).toBe(true); + }); + + it('should reject rotation messages with blank text', () => { + const errors = validateSingleValue('botStatus.rotation.messages', [ + { type: 'Watching', text: '' }, + ]); + expect(errors.some((e) => e.includes('at least 1 characters'))).toBe(true); + }); + + it('should reject rotation messages with unknown keys', () => { + const errors = validateSingleValue('botStatus.rotation.messages', [ + { type: 'Watching', text: '{guildCount} servers', extra: true }, + ]); + expect(errors.some((e) => e.includes('unknown config key'))).toBe(true); + }); + }); + describe('auditLog schema validation', () => { it('should accept valid auditLog.enabled boolean', () => { expect(validateSingleValue('auditLog.enabled', true)).toEqual([]); diff --git a/tests/api/utils/validateConfigPatch.test.js b/tests/api/utils/validateConfigPatch.test.js index 510c34e14..64d730eb6 100644 --- a/tests/api/utils/validateConfigPatch.test.js +++ b/tests/api/utils/validateConfigPatch.test.js @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import { SAFE_CONFIG_KEYS } from '../../../src/api/utils/configAllowlist.js'; import { validateConfigPatchBody } from '../../../src/api/utils/validateConfigPatch.js'; // Mock the validateSingleValue function from config.js @@ -12,8 +13,6 @@ vi.mock('../../../src/api/routes/config.js', () => ({ }), })); -const SAFE_CONFIG_KEYS = new Set(['ai', 'welcome', 'spam', 'moderation', 'triage']); - describe('validateConfigPatch', () => { describe('validateConfigPatchBody', () => { it('should validate a correct config patch body', () => { @@ -86,7 +85,7 @@ describe('validateConfigPatch', () => { it('should reject unsafe top-level keys', () => { const body = { - path: 'permissions.botOwners', + path: 'database.password', value: ['user123'], }; diff --git a/tests/commands/voice.test.js b/tests/commands/voice.test.js index 8b6b6f7cc..e74c2e3a5 100644 --- a/tests/commands/voice.test.js +++ b/tests/commands/voice.test.js @@ -263,7 +263,9 @@ describe('voice command', () => { }), ); expect(csv).toContain('id,user_id,channel_id,joined_at,left_at,duration_seconds'); - expect(csv).toContain('1,user-1,channel-1,2025-01-01T00:00:00.000Z,2025-01-01T01:00:00.000Z,3600'); + expect(csv).toContain( + '1,user-1,channel-1,2025-01-01T00:00:00.000Z,2025-01-01T01:00:00.000Z,3600', + ); }); it('shows a failure message when export throws', async () => { diff --git a/tests/config-listeners.test.js b/tests/config-listeners.test.js index 26054bf98..1816016c1 100644 --- a/tests/config-listeners.test.js +++ b/tests/config-listeners.test.js @@ -92,12 +92,15 @@ describe('config-listeners', () => { expect(registeredKeys).toContain('welcome.*'); expect(registeredKeys).toContain('starboard.*'); expect(registeredKeys).toContain('reputation.*'); + expect(registeredKeys).toContain('botStatus.rotation.enabled'); + expect(registeredKeys).toContain('botStatus.rotation.intervalMinutes'); + expect(registeredKeys).toContain('botStatus.rotation.messages'); }); - it('registers exactly 18 listeners', () => { + it('registers exactly 22 listeners', () => { const config = { logging: { database: { enabled: false } } }; registerConfigListeners({ dbPool: {}, config }); - expect(onConfigChange).toHaveBeenCalledTimes(18); + expect(onConfigChange).toHaveBeenCalledTimes(22); }); }); diff --git a/tests/index.test.js b/tests/index.test.js index 8ee9e1897..a26c74073 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -62,6 +62,11 @@ const mocks = vi.hoisted(() => ({ stopTempbanScheduler: vi.fn(), }, + botStatus: { + startBotStatus: vi.fn(), + stopBotStatus: vi.fn(), + }, + health: { instance: {}, getInstance: vi.fn(), @@ -205,6 +210,11 @@ vi.mock('../src/utils/health.js', () => ({ }, })); +vi.mock('../src/modules/botStatus.js', () => ({ + startBotStatus: mocks.botStatus.startBotStatus, + stopBotStatus: mocks.botStatus.stopBotStatus, +})); + vi.mock('../src/utils/permissions.js', () => ({ hasPermission: mocks.permissions.hasPermission, getPermissionError: mocks.permissions.getPermissionError, @@ -338,6 +348,7 @@ async function importIndex({ describe('index.js', () => { beforeEach(() => { + vi.clearAllMocks(); delete process.env.DISCORD_TOKEN; delete process.env.DATABASE_URL; }); @@ -369,6 +380,16 @@ describe('index.js', () => { expect(mocks.client.login).toHaveBeenCalledWith('abc'); }); + it('should start bot status after client login completes', async () => { + await importIndex({ token: 'abc', databaseUrl: null }); + + expect(mocks.client.login).toHaveBeenCalledWith('abc'); + expect(mocks.botStatus.startBotStatus).toHaveBeenCalledWith(mocks.client); + expect(mocks.client.login.mock.invocationCallOrder.at(-1)).toBeLessThan( + mocks.botStatus.startBotStatus.mock.invocationCallOrder.at(-1), + ); + }); + it('should warn and skip db init when DATABASE_URL is not set', async () => { await importIndex({ token: 'abc', databaseUrl: null }); @@ -426,6 +447,7 @@ describe('index.js', () => { expect(mocks.fs.writeFileSync).toHaveBeenCalled(); expect(mocks.db.closeDb).toHaveBeenCalled(); expect(mocks.client.destroy).toHaveBeenCalled(); + expect(mocks.botStatus.stopBotStatus).toHaveBeenCalled(); }); it('should log save-state failure during shutdown', async () => { diff --git a/tests/modules/botStatus.test.js b/tests/modules/botStatus.test.js index f4abbf616..a2cabf580 100644 --- a/tests/modules/botStatus.test.js +++ b/tests/modules/botStatus.test.js @@ -11,12 +11,15 @@ vi.mock('../../src/modules/config.js', () => ({ })); import { ActivityType } from 'discord.js'; +import { warn } from '../../src/logger.js'; import { applyPresence, getActivities, + getRotationMessages, interpolateActivity, reloadBotStatus, resolvePresenceConfig, + resolveRotationIntervalMs, startBotStatus, stopBotStatus, } from '../../src/modules/botStatus.js'; @@ -103,6 +106,21 @@ describe('interpolateActivity', () => { const result = interpolateActivity('{memberCount}', client); expect(result).toBe('0'); }); + + it('replaces {commandCount} and {uptime}', () => { + const client = makeClient(); + client.commands = { size: 42 }; + client.uptime = 3_660_000; // 1h 1m + const result = interpolateActivity('{commandCount} commands - {uptime}', client); + expect(result).toBe('42 commands - 1h 1m'); + }); + + it('replaces {version} from package.json', () => { + const client = makeClient(); + const result = interpolateActivity('v{version}', client); + expect(result).not.toContain('{version}'); + expect(result).toMatch(/^v.+/); + }); }); // ── resolvePresenceConfig ────────────────────────────────────────────────── @@ -193,6 +211,98 @@ describe('getActivities', () => { }); }); +describe('getRotationMessages / resolveRotationIntervalMs', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('uses rotation.messages when configured', () => { + const messages = getRotationMessages({ + rotation: { + messages: [ + { type: 'Watching', text: '{guildCount} servers' }, + { type: 'Playing', text: 'with /help' }, + ], + }, + }); + expect(messages).toEqual([ + { type: 'Watching', text: '{guildCount} servers' }, + { type: 'Playing', text: 'with /help' }, + ]); + }); + + it('uses intervalMinutes from rotation config', () => { + const intervalMs = resolveRotationIntervalMs({ rotation: { intervalMinutes: 5 } }); + expect(intervalMs).toBe(300_000); + }); + + it('clamps rotation intervalMinutes to the Discord-safe minimum', () => { + const intervalMs = resolveRotationIntervalMs({ rotation: { intervalMinutes: 0.001 } }); + expect(intervalMs).toBe(20_000); + }); + + it('clamps legacy rotateIntervalMs to the Discord-safe minimum', () => { + const intervalMs = resolveRotationIntervalMs({ rotateIntervalMs: 100 }); + expect(intervalMs).toBe(20_000); + }); + + it('warns and falls back when activityType cannot be resolved', () => { + const messages = getRotationMessages({ + activityType: 'BadType', + activities: ['with /help'], + }); + + expect(messages).toEqual([{ type: 'Playing', text: 'with /help' }]); + expect(warn).toHaveBeenCalledWith( + 'Invalid bot status activity type, falling back to Playing', + expect.objectContaining({ + invalidType: 'BadType', + }), + ); + }); + + it('warns and falls back when a rotation message type is invalid', () => { + const messages = getRotationMessages({ + activityType: 'Watching', + rotation: { + messages: [{ type: 'BadType', text: '{guildCount} servers' }], + }, + }); + + expect(messages).toEqual([{ type: 'Watching', text: '{guildCount} servers' }]); + expect(warn).toHaveBeenCalledWith( + 'Invalid bot status message type, falling back to configured/default type', + expect.objectContaining({ + invalidType: 'BadType', + fallbackType: 'Watching', + }), + ); + }); + + it('warns when configured messages are unusable and default is used', () => { + const messages = getRotationMessages({ + rotation: { + messages: [{ type: 'Watching', text: ' ' }], + }, + activities: [], + }); + + expect(messages).toEqual([{ type: 'Playing', text: 'with Discord' }]); + expect(warn).toHaveBeenCalledWith( + 'Ignoring bot status message without valid text', + expect.objectContaining({ + source: 'botStatus.rotation.messages[0]', + }), + ); + expect(warn).toHaveBeenCalledWith( + 'Configured botStatus.rotation.messages had no usable entries; falling back', + expect.objectContaining({ + fallback: 'default', + }), + ); + }); +}); + // ── applyPresence ────────────────────────────────────────────────────────── describe('applyPresence', () => { @@ -248,7 +358,6 @@ describe('applyPresence', () => { }); it('warns instead of throwing when setPresence throws', async () => { - const { warn } = await import('../../src/logger.js'); getConfig.mockReturnValue(makeConfig({ activities: ['hi'] })); const client = makeClient(); client.user.setPresence = vi.fn(() => { @@ -282,7 +391,7 @@ describe('startBotStatus / stopBotStatus', () => { it('rotates activities on interval', () => { const cfg = makeConfig({ activities: ['activity A', 'activity B', 'activity C'], - rotateIntervalMs: 100, + rotateIntervalMs: 20_000, }); getConfig.mockReturnValue(cfg); const client = makeClient(); @@ -290,10 +399,10 @@ describe('startBotStatus / stopBotStatus', () => { startBotStatus(client); expect(client.user.setPresence).toHaveBeenCalledTimes(1); - vi.advanceTimersByTime(100); + vi.advanceTimersByTime(20_000); expect(client.user.setPresence).toHaveBeenCalledTimes(2); - vi.advanceTimersByTime(100); + vi.advanceTimersByTime(20_000); expect(client.user.setPresence).toHaveBeenCalledTimes(3); }); @@ -308,16 +417,16 @@ describe('startBotStatus / stopBotStatus', () => { }); it('stops rotation on stopBotStatus', () => { - const cfg = makeConfig({ activities: ['A', 'B'], rotateIntervalMs: 100 }); + const cfg = makeConfig({ activities: ['A', 'B'], rotateIntervalMs: 20_000 }); getConfig.mockReturnValue(cfg); const client = makeClient(); startBotStatus(client); - vi.advanceTimersByTime(100); + vi.advanceTimersByTime(20_000); expect(client.user.setPresence).toHaveBeenCalledTimes(2); stopBotStatus(); - vi.advanceTimersByTime(500); + vi.advanceTimersByTime(20_000); // No new calls after stop expect(client.user.setPresence).toHaveBeenCalledTimes(2); }); @@ -346,7 +455,7 @@ describe('startBotStatus / stopBotStatus', () => { it('wraps activity index around when activities cycle through', () => { const cfg = makeConfig({ activities: ['first', 'second'], - rotateIntervalMs: 100, + rotateIntervalMs: 20_000, }); getConfig.mockReturnValue(cfg); const client = makeClient(); @@ -356,13 +465,35 @@ describe('startBotStatus / stopBotStatus', () => { const calls = client.user.setPresence.mock.calls; expect(calls[0][0].activities[0].name).toBe('first'); - vi.advanceTimersByTime(100); + vi.advanceTimersByTime(20_000); expect(calls[1][0].activities[0].name).toBe('second'); - vi.advanceTimersByTime(100); + vi.advanceTimersByTime(20_000); // Wraps back to first expect(calls[2][0].activities[0].name).toBe('first'); }); + + it('rotates using new rotation config shape', () => { + const cfg = makeConfig({ + activities: undefined, + rotation: { + enabled: true, + intervalMinutes: 0.001, + messages: [ + { type: 'Watching', text: 'first' }, + { type: 'Playing', text: 'second' }, + ], + }, + }); + getConfig.mockReturnValue(cfg); + const client = makeClient(); + + startBotStatus(client); + expect(client.user.setPresence.mock.calls[0][0].activities[0].name).toBe('first'); + + vi.advanceTimersByTime(20_000); + expect(client.user.setPresence.mock.calls[1][0].activities[0].name).toBe('second'); + }); }); // ── reloadBotStatus ──────────────────────────────────────────────────────── diff --git a/tests/modules/events-extra.test.js b/tests/modules/events-extra.test.js index dee5bad2e..607100b71 100644 --- a/tests/modules/events-extra.test.js +++ b/tests/modules/events-extra.test.js @@ -87,7 +87,10 @@ import { } from '../../src/modules/events.js'; import { handleChallengeButton } from '../../src/modules/handlers/challengeHandler.js'; import { handleReviewButton } from '../../src/modules/handlers/reviewHandler.js'; -import { handleShowcaseButton, handleShowcaseModal } from '../../src/modules/handlers/showcaseHandler.js'; +import { + handleShowcaseButton, + handleShowcaseModal, +} from '../../src/modules/handlers/showcaseHandler.js'; import { checkLinks } from '../../src/modules/linkFilter.js'; import { checkRateLimit } from '../../src/modules/rateLimit.js'; import { handleReviewClaim } from '../../src/modules/reviewHandler.js'; @@ -114,21 +117,25 @@ describe('handleReviewButton', () => { it('should skip when review feature is disabled', async () => { getConfig.mockReturnValue({ review: { enabled: false } }); - expect(await handleReviewButton({ - isButton: () => true, - customId: 'review_claim_123', - guildId: 'g1', - })).toBe(true); + expect( + await handleReviewButton({ + isButton: () => true, + customId: 'review_claim_123', + guildId: 'g1', + }), + ).toBe(true); expect(handleReviewClaim).not.toHaveBeenCalled(); }); it('should skip when review config is absent', async () => { getConfig.mockReturnValue({}); - expect(await handleReviewButton({ - isButton: () => true, - customId: 'review_claim_123', - guildId: 'g1', - })).toBe(true); + expect( + await handleReviewButton({ + isButton: () => true, + customId: 'review_claim_123', + guildId: 'g1', + }), + ).toBe(true); expect(handleReviewClaim).not.toHaveBeenCalled(); }); @@ -180,15 +187,17 @@ describe('handleReviewButton', () => { getConfig.mockReturnValue({ review: { enabled: true } }); handleReviewClaim.mockRejectedValueOnce(new Error('boom')); const reply = vi.fn().mockRejectedValue(new Error('reply also failed')); - await expect(handleReviewButton({ - isButton: () => true, - customId: 'review_claim_789', - guildId: 'g1', - user: { id: 'u1' }, - replied: false, - deferred: false, - reply, - })).resolves.toBe(true); + await expect( + handleReviewButton({ + isButton: () => true, + customId: 'review_claim_789', + guildId: 'g1', + user: { id: 'u1' }, + replied: false, + deferred: false, + reply, + }), + ).resolves.toBe(true); }); }); @@ -253,7 +262,9 @@ describe('handleShowcaseModal', () => { }); it('should ignore modals with wrong customId', async () => { - expect(await handleShowcaseModal({ isModalSubmit: () => true, customId: 'other_modal' })).toBe(false); + expect(await handleShowcaseModal({ isModalSubmit: () => true, customId: 'other_modal' })).toBe( + false, + ); expect(handleShowcaseModalSubmit).not.toHaveBeenCalled(); }); @@ -308,7 +319,9 @@ describe('handleChallengeButton', () => { }); it('should ignore buttons with unrelated customId', async () => { - expect(await handleChallengeButton({ isButton: () => true, customId: 'other_button' })).toBe(false); + expect(await handleChallengeButton({ isButton: () => true, customId: 'other_button' })).toBe( + false, + ); expect(handleSolveButton).not.toHaveBeenCalled(); }); @@ -338,12 +351,14 @@ describe('handleChallengeButton', () => { it('should return true on NaN challenge index', async () => { getConfig.mockReturnValue({ challenges: { enabled: true } }); - expect(await handleChallengeButton({ - isButton: () => true, - customId: 'challenge_solve_abc', - user: { id: 'u1' }, - guildId: 'g1', - })).toBe(true); + expect( + await handleChallengeButton({ + isButton: () => true, + customId: 'challenge_solve_abc', + user: { id: 'u1' }, + guildId: 'g1', + }), + ).toBe(true); expect(handleSolveButton).not.toHaveBeenCalled(); }); diff --git a/tests/utils/discordCache.test.js b/tests/utils/discordCache.test.js index 3086f976b..a8e733ade 100644 --- a/tests/utils/discordCache.test.js +++ b/tests/utils/discordCache.test.js @@ -263,9 +263,7 @@ describe('discordCache.js', () => { const result = await discordCache.fetchMemberCached(guild, 'member-1'); expect(result).toBe(mockMember); - expect( - await cache.cacheGet('discord:guild:guild1:member:member-1'), - ).toEqual({ + expect(await cache.cacheGet('discord:guild:guild1:member:member-1')).toEqual({ id: 'member-1', displayName: 'Member One', joinedAt: '2025-01-01T00:00:00.000Z', diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 7538d140d..e7e173ed5 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -93,12 +93,22 @@ export default function LandingPage() { {mobileMenuOpen ? ( Close menu - + ) : ( Open menu - + )} @@ -131,10 +141,18 @@ export default function LandingPage() { > Pricing - + Docs - + GitHub
diff --git a/web/src/components/dashboard/config-sections/CommunitySettingsSection.tsx b/web/src/components/dashboard/config-sections/CommunitySettingsSection.tsx index 1329d04f3..efe89affe 100644 --- a/web/src/components/dashboard/config-sections/CommunitySettingsSection.tsx +++ b/web/src/components/dashboard/config-sections/CommunitySettingsSection.tsx @@ -130,6 +130,105 @@ export function CommunitySettingsSection({ /> )} + {showFeature('bot-status') && activeCategoryId === 'community-tools' && ( + + updateDraftConfig((prev) => ({ + ...prev, + botStatus: { ...prev.botStatus, enabled: value }, + })) + } + disabled={saving} + basicContent={ +
+
+ + +
+
+
+

Enable Rotation

+

+ Rotate through configured presence messages. +

+
+ { + updateDraftConfig((prev) => { + const legacyMs = prev.botStatus?.rotateIntervalMs; + const legacyMinutes = legacyMs != null ? legacyMs / 60000 : 5; + return { + ...prev, + botStatus: { + ...prev.botStatus, + rotation: { + ...prev.botStatus?.rotation, + enabled: value, + intervalMinutes: value + ? (prev.botStatus?.rotation?.intervalMinutes ?? legacyMinutes) + : prev.botStatus?.rotation?.intervalMinutes, + }, + }, + }; + }); + }} + disabled={saving} + aria-label="Enable bot status rotation" + /> +
+
+ } + advancedContent={ +
+ + { + const num = parseNumberInput(event.target.value, 1); + if (num === undefined) return; + updateDraftConfig((prev) => ({ + ...prev, + botStatus: { + ...prev.botStatus, + rotation: { ...prev.botStatus?.rotation, intervalMinutes: num }, + }, + })); + }} + disabled={saving} + /> +
+ } + forceOpenAdvanced={forceOpenAdvancedFeatureId === 'bot-status'} + /> + )} + {showFeature('engagement') && activeCategoryId === 'onboarding-growth' && ( ✕ diff --git a/web/src/components/dashboard/config-workspace/config-categories.ts b/web/src/components/dashboard/config-workspace/config-categories.ts index fae1a986b..84ec8b9d3 100644 --- a/web/src/components/dashboard/config-workspace/config-categories.ts +++ b/web/src/components/dashboard/config-workspace/config-categories.ts @@ -36,8 +36,16 @@ export const CONFIG_CATEGORIES: ConfigCategoryMeta[] = [ icon: 'bot', label: 'Community Tools', description: 'Member-facing utility commands and review workflows.', - sectionKeys: ['help', 'announce', 'snippet', 'poll', 'showcase', 'review'], - featureIds: ['community-tools'], + sectionKeys: [ + 'help', + 'announce', + 'snippet', + 'poll', + 'showcase', + 'review', + 'botStatus', + ], + featureIds: ['community-tools', 'bot-status'], }, { id: 'support-integrations', @@ -68,6 +76,7 @@ export const FEATURE_LABELS: Record = { tickets: 'Tickets', 'github-feed': 'GitHub Activity Feed', 'audit-log': 'Audit Log', + 'bot-status': 'Bot Presence', }; export const CONFIG_SEARCH_ITEMS: ConfigSearchItem[] = [ @@ -350,6 +359,15 @@ export const CONFIG_SEARCH_ITEMS: ConfigSearchItem[] = [ keywords: ['audit', 'retention', 'purge', 'days', 'cleanup'], isAdvanced: true, }, + { + id: 'bot-status-enabled', + featureId: 'bot-status', + categoryId: 'community-tools', + label: 'Bot Presence Rotation', + description: 'Configure rotating bot status messages and interval.', + keywords: ['bot status', 'presence', 'rotation', 'activity'], + isAdvanced: false, + }, ]; /** diff --git a/web/src/components/dashboard/config-workspace/types.ts b/web/src/components/dashboard/config-workspace/types.ts index 2c4d8ca4d..f8eb1cbdf 100644 --- a/web/src/components/dashboard/config-workspace/types.ts +++ b/web/src/components/dashboard/config-workspace/types.ts @@ -25,7 +25,8 @@ export type ConfigFeatureId = | 'community-tools' | 'tickets' | 'github-feed' - | 'audit-log'; + | 'audit-log' + | 'bot-status'; export type ConfigSectionKey = ConfigSection | 'aiAutoMod'; diff --git a/web/src/components/landing/FeatureGrid.tsx b/web/src/components/landing/FeatureGrid.tsx index 42b5cb14f..b9ea22348 100644 --- a/web/src/components/landing/FeatureGrid.tsx +++ b/web/src/components/landing/FeatureGrid.tsx @@ -1,8 +1,8 @@ 'use client'; import { motion, useInView, useReducedMotion } from 'framer-motion'; -import { BarChart3, MessageSquare, Shield, Star } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; +import { BarChart3, MessageSquare, Shield, Star } from 'lucide-react'; import { useRef } from 'react'; const features: { icon: LucideIcon; title: string; description: string; color: string }[] = [ diff --git a/web/src/components/landing/Footer.tsx b/web/src/components/landing/Footer.tsx index 5487ac10c..3eaf00bb6 100644 --- a/web/src/components/landing/Footer.tsx +++ b/web/src/components/landing/Footer.tsx @@ -23,9 +23,7 @@ export function Footer() { className="mb-16" >

- Ready to{' '} - upgrade - ? + Ready to upgrade?

Join thousands of developers who've switched from MEE6, Dyno, and Carl-bot. Your @@ -71,15 +69,24 @@ export function Footer() { transition={{ duration: 0.6, delay: 0.3 }} className="flex flex-wrap justify-center gap-8 mb-12" > - + Documentation - + GitHub - + Support Server @@ -94,7 +101,8 @@ export function Footer() { className="pt-8 border-t border-border" >

- Made with by developers, for developers + Made with by developers, for + developers

© {new Date().getFullYear()} Volvox. Not affiliated with Discord. diff --git a/web/src/components/landing/Hero.tsx b/web/src/components/landing/Hero.tsx index f856b16ab..048030424 100644 --- a/web/src/components/landing/Hero.tsx +++ b/web/src/components/landing/Hero.tsx @@ -45,7 +45,10 @@ function useTypewriter(text: string, speed = 100, delay = 500) { function BlinkingCursor() { return ( -

- Live + + Live +
{/* Messages */} -
+
{messages.map((msg) => ( +
@@ -394,8 +446,8 @@ export function Hero() { transition={{ duration: 0.5, delay: 0.2 }} className="text-[clamp(1rem,2vw,1.25rem)] text-foreground/70 leading-relaxed mb-10 max-w-[700px] mx-auto" > - A software-powered bot for modern communities. Moderation, AI chat, - dynamic welcomes, and a fully configurable dashboard — all in one place. + A software-powered bot for modern communities. Moderation, AI chat, dynamic welcomes, and + a fully configurable dashboard — all in one place. {/* CTA Buttons */} @@ -405,7 +457,10 @@ export function Hero() { transition={{ duration: 0.5, delay: 0.4 }} className="flex flex-col gap-4 sm:flex-row justify-center mb-16" > - + - - + Annual Save 36%
@@ -152,9 +160,13 @@ export function Pricing() { disabled={!tier.href && !botInviteUrl} > {tier.href ? ( - {tier.cta} + + {tier.cta} + ) : botInviteUrl ? ( - {tier.cta} + + {tier.cta} + ) : ( {tier.cta} )} diff --git a/web/src/components/landing/Stats.tsx b/web/src/components/landing/Stats.tsx index 833df5e8c..154834973 100644 --- a/web/src/components/landing/Stats.tsx +++ b/web/src/components/landing/Stats.tsx @@ -85,7 +85,14 @@ function SkeletonCard() {
@@ -100,16 +107,24 @@ function SkeletonCard() { // ─── Stat Card ──────────────────────────────────────────────────────────────── interface StatCardProps { - icon: React.ReactNode; - color: string; - value: number; - label: string; - formatter?: (n: number) => string; - delay: number; - isInView: boolean; + readonly icon: React.ReactNode; + readonly color: string; + readonly value: number; + readonly label: string; + readonly formatter?: (n: number) => string; + readonly delay: number; + readonly isInView: boolean; } -function StatCard({ icon, color, value, label, formatter = formatNumber, delay, isInView }: StatCardProps) { +function StatCard({ + icon, + color, + value, + label, + formatter = formatNumber, + delay, + isInView, +}: StatCardProps) { return ( , color: '#22c55e', value: s.servers, label: 'Servers', formatter: formatNumber }, - { icon: , color: '#22c55e', value: s.members, label: 'Members', formatter: formatNumber }, - { icon: , color: '#ff9500', value: s.commandsServed, label: 'Commands Served', formatter: formatNumber }, - { icon: , color: '#af58da', value: s.activeConversations, label: 'Active Conversations', formatter: formatNumber }, - { icon: , color: '#14b8a6', value: s.uptime, label: 'Uptime', formatter: formatUptime }, - { icon: , color: '#f43f5e', value: s.messagesProcessed, label: 'Messages Processed', formatter: formatNumber }, + { + icon: , + color: '#22c55e', + value: s.servers, + label: 'Servers', + formatter: formatNumber, + }, + { + icon: , + color: '#22c55e', + value: s.members, + label: 'Members', + formatter: formatNumber, + }, + { + icon: , + color: '#ff9500', + value: s.commandsServed, + label: 'Commands Served', + formatter: formatNumber, + }, + { + icon: , + color: '#af58da', + value: s.activeConversations, + label: 'Active Conversations', + formatter: formatNumber, + }, + { + icon: , + color: '#14b8a6', + value: s.uptime, + label: 'Uptime', + formatter: formatUptime, + }, + { + icon: , + color: '#f43f5e', + value: s.messagesProcessed, + label: 'Messages Processed', + formatter: formatNumber, + }, ]; return ( @@ -255,7 +311,13 @@ export function Stats() { transition={{ duration: 0.5, delay: i * 0.07 }} className="p-6 rounded-2xl border border-border bg-card text-center" > -
+
{card.icon}
diff --git a/web/src/types/config.ts b/web/src/types/config.ts index 75fb8f3dd..586292fcf 100644 --- a/web/src/types/config.ts +++ b/web/src/types/config.ts @@ -246,6 +246,27 @@ export interface ToggleSectionConfig { enabled: boolean; } +export interface BotStatusRotationMessage { + type?: 'Playing' | 'Watching' | 'Listening' | 'Competing' | 'Custom'; + text: string; +} + +export interface BotStatusRotationConfig { + enabled?: boolean; + intervalMinutes?: number; + messages?: BotStatusRotationMessage[]; +} + +export interface BotStatusConfig { + enabled?: boolean; + status?: 'online' | 'idle' | 'dnd' | 'invisible'; + rotation?: BotStatusRotationConfig; + // Legacy fields for backward compatibility + activityType?: string; + activities?: string[]; + rotateIntervalMs?: number; +} + /** TL;DR summary feature settings. */ export interface TldrConfig extends ToggleSectionConfig { defaultMessages: number; @@ -348,6 +369,7 @@ export interface BotConfig { challenges?: ChallengesConfig; tickets?: TicketsConfig; auditLog?: AuditLogConfig; + botStatus?: BotStatusConfig; } /** All config sections shown in the editor. */ @@ -373,7 +395,8 @@ export type ConfigSection = | 'review' | 'challenges' | 'tickets' - | 'auditLog'; + | 'auditLog' + | 'botStatus'; /** * @deprecated Use {@link ConfigSection} directly.