diff --git a/config.json b/config.json index af13a135..0ea9fad9 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/utils/configValidation.js b/src/api/utils/configValidation.js index 9f3319d0..f76af82d 100644 --- a/src/api/utils/configValidation.js +++ b/src/api/utils/configValidation.js @@ -177,6 +177,33 @@ export const CONFIG_SCHEMA = { retentionDays: { type: 'number' }, }, }, + 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', + required: ['text'], + }, + }, + }, + }, + }, + }, reminders: { type: 'object', properties: { diff --git a/src/config-listeners.js b/src/config-listeners.js index d519f1a3..5496c451 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 a42ba219..ed175f5d 100644 --- a/src/index.js +++ b/src/index.js @@ -43,8 +43,8 @@ import { startConversationCleanup, stopConversationCleanup, } from './modules/ai.js'; +import { startBotStatus, stopBotStatus } from './modules/botStatus.js'; import { getConfig, loadConfig } from './modules/config.js'; - import { registerEventHandlers } from './modules/events.js'; import { startGithubFeed, stopGithubFeed } from './modules/githubFeed.js'; import { checkMem0Health, markUnavailable } from './modules/memory.js'; @@ -292,6 +292,7 @@ async function gracefulShutdown(signal) { stopWarningExpiryScheduler(); stopScheduler(); stopGithubFeed(); + stopBotStatus(); // 1.5. Stop API server (drain in-flight HTTP requests before closing DB) try { @@ -482,6 +483,9 @@ async function startup() { // Start triage module (per-channel message classification + response) await startTriage(client, config, healthMonitor); + // Start configurable bot presence rotation + startBotStatus(client); + // Start tempban scheduler for automatic unbans (DB required) if (dbPool) { startTempbanScheduler(client); diff --git a/src/modules/botStatus.js b/src/modules/botStatus.js index e6662f4e..7998c4fa 100644 --- a/src/modules/botStatus.js +++ b/src/modules/botStatus.js @@ -1,34 +1,41 @@ /** * 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'; +const __dirname = dirname(fileURLToPath(import.meta.url)); + /** Map Discord activity type strings to ActivityType enum values */ const ACTIVITY_TYPE_MAP = { Playing: ActivityType.Playing, @@ -42,6 +49,9 @@ const ACTIVITY_TYPE_MAP = { /** 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; + /** @type {ReturnType | null} */ let rotateInterval = null; @@ -51,6 +61,46 @@ 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; +} + /** * Interpolate variables in an activity text string. * @@ -64,42 +114,170 @@ 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); + .replace(/\{botName\}/g, botName) + .replace(/\{commandCount\}/g, String(commandCount)) + .replace(/\{uptime\}/g, uptime) + .replace(/\{version\}/g, 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; + return ACTIVITY_TYPE_MAP[typeStr] !== undefined + ? ACTIVITY_TYPE_MAP[typeStr] + : 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), + }; +} + +/** + * Normalize a configured status message entry. + * + * @param {unknown} entry + * @param {string | undefined} fallbackType + * @returns {{type: string, text: string} | null} + */ +function normalizeMessage(entry, fallbackType) { + if (typeof entry === 'string') { + const text = entry.trim(); + if (!text) return null; + return { type: fallbackType ?? 'Playing', text }; + } - const typeStr = cfg?.activityType ?? 'Playing'; - const activityType = - ACTIVITY_TYPE_MAP[typeStr] !== undefined ? ACTIVITY_TYPE_MAP[typeStr] : ActivityType.Playing; + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + return null; + } - return { status, activityType }; + const rawText = typeof entry.text === 'string' ? entry.text.trim() : ''; + if (!rawText) return null; + + const type = + typeof entry.type === 'string' && entry.type.trim() ? entry.type : (fallbackType ?? 'Playing'); + 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) => normalizeMessage(entry, cfg?.activityType)) + .filter((entry) => entry !== null); + if (normalized.length > 0) { + return normalized; + } + } + + const legacyActivities = cfg?.activities; + if (Array.isArray(legacyActivities)) { + const normalized = legacyActivities + .map((entry) => normalizeMessage(entry, cfg?.activityType)) + .filter((entry) => entry !== null); + if (normalized.length > 0) { + return normalized; + } + } + + return [{ type: 'Playing', text: 'with Discord' }]; +} + +/** + * 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.round(cfg.rotation.intervalMinutes * 60_000); + } + + if (typeof cfg?.rotateIntervalMs === 'number' && cfg.rotateIntervalMs > 0) { + return cfg.rotateIntervalMs; } - return ['with Discord']; + + if (cfg?.rotation) { + return DEFAULT_ROTATE_INTERVAL_MINUTES * 60_000; + } + + 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 +286,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) { @@ -148,8 +324,8 @@ export function applyPresence(client) { */ function rotate(client) { const cfg = getConfig()?.botStatus; - const activities = getActivities(cfg); - currentActivityIndex = (currentActivityIndex + 1) % Math.max(activities.length, 1); + const messages = getRotationMessages(cfg); + currentActivityIndex = (currentActivityIndex + 1) % Math.max(messages.length, 1); applyPresence(client); } @@ -160,36 +336,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,13 +380,12 @@ 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. * * @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) { diff --git a/src/modules/events/messageCreate.js b/src/modules/events/messageCreate.js index b12127c5..0fed0243 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/tests/api/routes/warnings.test.js b/tests/api/routes/warnings.test.js index 0a454639..9e47daa3 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 183333f1..78ec4b8a 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 adcba184..c521cb99 100644 --- a/tests/api/utils/configValidation.test.js +++ b/tests/api/utils/configValidation.test.js @@ -104,11 +104,44 @@ 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', + ]), ); }); }); + 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); + }); + }); + 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 510c34e1..c347d68a 100644 --- a/tests/api/utils/validateConfigPatch.test.js +++ b/tests/api/utils/validateConfigPatch.test.js @@ -12,7 +12,7 @@ vi.mock('../../../src/api/routes/config.js', () => ({ }), })); -const SAFE_CONFIG_KEYS = new Set(['ai', 'welcome', 'spam', 'moderation', 'triage']); +const SAFE_CONFIG_KEYS = new Set(['ai', 'welcome', 'spam', 'moderation', 'triage', 'botStatus']); describe('validateConfigPatch', () => { describe('validateConfigPatchBody', () => { diff --git a/tests/commands/voice.test.js b/tests/commands/voice.test.js index 8b6b6f7c..e74c2e3a 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 26054bf9..1816016c 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/modules/botStatus.test.js b/tests/modules/botStatus.test.js index f4abbf61..d918d671 100644 --- a/tests/modules/botStatus.test.js +++ b/tests/modules/botStatus.test.js @@ -14,9 +14,11 @@ import { ActivityType } from 'discord.js'; import { applyPresence, getActivities, + getRotationMessages, interpolateActivity, reloadBotStatus, resolvePresenceConfig, + resolveRotationIntervalMs, startBotStatus, stopBotStatus, } from '../../src/modules/botStatus.js'; @@ -103,6 +105,20 @@ 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).toMatch(/^v.+/); + }); }); // ── resolvePresenceConfig ────────────────────────────────────────────────── @@ -193,6 +209,28 @@ describe('getActivities', () => { }); }); +describe('getRotationMessages / resolveRotationIntervalMs', () => { + 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); + }); +}); + // ── applyPresence ────────────────────────────────────────────────────────── describe('applyPresence', () => { @@ -363,6 +401,28 @@ describe('startBotStatus / stopBotStatus', () => { // 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, // 60ms for test speed + 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(60); + expect(client.user.setPresence.mock.calls[1][0].activities[0].name).toBe('second'); + }); }); // ── reloadBotStatus ──────────────────────────────────────────────────────── diff --git a/tests/utils/discordCache.test.js b/tests/utils/discordCache.test.js index 3086f976..a8e733ad 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 07729702..5261d06f 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-editor.tsx b/web/src/components/dashboard/config-editor.tsx index 40ba2929..165ab1c6 100644 --- a/web/src/components/dashboard/config-editor.tsx +++ b/web/src/components/dashboard/config-editor.tsx @@ -116,6 +116,7 @@ function isGuildConfig(data: unknown): data is GuildConfig { 'challenges', 'tickets', 'auditLog', + 'botStatus', ] as const; const hasKnownSection = knownSections.some((key) => key in obj); if (!hasKnownSection) return false; @@ -940,6 +941,32 @@ export function ConfigEditor() { [updateDraftConfig], ); + const updateBotStatusField = useCallback( + (field: string, value: unknown) => { + updateDraftConfig((prev) => { + if (!prev) return prev; + return { ...prev, botStatus: { ...prev.botStatus, [field]: value } } as GuildConfig; + }); + }, + [updateDraftConfig], + ); + + const updateBotStatusRotationField = useCallback( + (field: string, value: unknown) => { + updateDraftConfig((prev) => { + if (!prev) return prev; + return { + ...prev, + botStatus: { + ...(prev.botStatus ?? {}), + rotation: { ...(prev.botStatus?.rotation ?? {}), [field]: value }, + }, + } as GuildConfig; + }); + }, + [updateDraftConfig], + ); + // ── No guild selected ────────────────────────────────────────── if (!guildId) { return ( @@ -2188,6 +2215,65 @@ export function ConfigEditor() { /> )} + {activeCategoryId === 'community-tools' && visibleFeatureIds.has('bot-status') && ( + updateBotStatusField('enabled', v)} + disabled={saving} + basicContent={ +
+ +
+ Enable Rotation + updateBotStatusRotationField('enabled', v)} + disabled={saving} + label="Enable Rotation" + /> +
+
+ } + advancedContent={ +
+ +
+ } + forceOpenAdvanced={forceOpenAdvancedFeatureId === 'bot-status'} + /> + )} + = { tickets: 'Tickets', 'github-feed': 'GitHub Activity Feed', 'audit-log': 'Audit Log', + 'bot-status': 'Bot Presence', }; export const CONFIG_SEARCH_ITEMS: ConfigSearchItem[] = [ @@ -349,6 +350,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 30411d86..5e5bf3b0 100644 --- a/web/src/components/dashboard/config-workspace/types.ts +++ b/web/src/components/dashboard/config-workspace/types.ts @@ -26,7 +26,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 42b5cb14..fa3bf058 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 }[] = [ @@ -61,12 +61,8 @@ function FeatureCard({ feature, index }: { feature: (typeof features)[0]; index:
{/* Content */} -

- {feature.title} -

-

- {feature.description} -

+

{feature.title}

+

{feature.description}

); } diff --git a/web/src/components/landing/Footer.tsx b/web/src/components/landing/Footer.tsx index 636fcaec..ede46927 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 f856b16a..04803042 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 +156,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 833df5e8..993f87c8 100644 --- a/web/src/components/landing/Stats.tsx +++ b/web/src/components/landing/Stats.tsx @@ -85,7 +85,8 @@ function SkeletonCard() {
@@ -109,7 +110,15 @@ interface StatCardProps { 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 +305,10 @@ 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 75fb8f3d..c784b572 100644 --- a/web/src/types/config.ts +++ b/web/src/types/config.ts @@ -246,6 +246,23 @@ export interface ToggleSectionConfig { enabled: boolean; } +export interface BotStatusRotationMessage { + type: 'Playing' | 'Watching' | 'Listening' | 'Competing' | 'Streaming' | 'Custom'; + text: string; +} + +export interface BotStatusRotationConfig { + enabled: boolean; + intervalMinutes: number; + messages: BotStatusRotationMessage[]; +} + +export interface BotStatusConfig { + enabled: boolean; + status: 'online' | 'idle' | 'dnd' | 'invisible'; + rotation: BotStatusRotationConfig; +} + /** TL;DR summary feature settings. */ export interface TldrConfig extends ToggleSectionConfig { defaultMessages: number; @@ -348,6 +365,7 @@ export interface BotConfig { challenges?: ChallengesConfig; tickets?: TicketsConfig; auditLog?: AuditLogConfig; + botStatus?: BotStatusConfig; } /** All config sections shown in the editor. */ @@ -373,7 +391,8 @@ export type ConfigSection = | 'review' | 'challenges' | 'tickets' - | 'auditLog'; + | 'auditLog' + | 'botStatus'; /** * @deprecated Use {@link ConfigSection} directly.