diff --git a/config.json b/config.json index c66fc0a7..785eefdd 100644 --- a/config.json +++ b/config.json @@ -282,6 +282,17 @@ "enabled": false, "maxPerUser": 25 }, + "botStatus": { + "enabled": true, + "status": "online", + "activityType": "Playing", + "activities": [ + "with {memberCount} members", + "in {guildCount} servers", + "your assistant | Volvox" + ], + "rotateIntervalMs": 30000 + }, "quietMode": { "enabled": false, "allowedRoles": [ @@ -289,11 +300,11 @@ ], "defaultDurationMinutes": 30, "maxDurationMinutes": 1440 - }, - "voice": { - "enabled": false, - "xpPerMinute": 2, - "dailyXpCap": 120, - "logChannel": null - } + }, + "voice": { + "enabled": false, + "xpPerMinute": 2, + "dailyXpCap": 120, + "logChannel": null + } } diff --git a/src/api/utils/configAllowlist.js b/src/api/utils/configAllowlist.js index cbe74c63..e06da182 100644 --- a/src/api/utils/configAllowlist.js +++ b/src/api/utils/configAllowlist.js @@ -28,6 +28,7 @@ export const SAFE_CONFIG_KEYS = new Set([ 'review', 'auditLog', 'reminders', + 'botStatus', 'quietMode', ]); diff --git a/src/config-listeners.js b/src/config-listeners.js index c9509170..65c0d219 100644 --- a/src/config-listeners.js +++ b/src/config-listeners.js @@ -9,6 +9,7 @@ */ import { addPostgresTransport, error, info, removePostgresTransport } from './logger.js'; +import { reloadBotStatus } from './modules/botStatus.js'; import { onConfigChange } from './modules/config.js'; import { cacheDelPattern } from './utils/cache.js'; @@ -103,6 +104,22 @@ export function registerConfigListeners({ dbPool, config }) { await cacheDelPattern(`discord:guild:${guildId}:*`).catch(() => {}); } }); + // ── Bot status / presence hot-reload ─────────────────────────────── + for (const key of [ + 'botStatus', + 'botStatus.enabled', + 'botStatus.status', + 'botStatus.activityType', + 'botStatus.activities', + 'botStatus.rotateIntervalMs', + ]) { + onConfigChange(key, (_newValue, _oldValue, _path, guildId) => { + // Bot presence is global — ignore per-guild overrides here + if (guildId && guildId !== 'global') return; + reloadBotStatus(); + }); + } + onConfigChange('reputation.*', async (_newValue, _oldValue, _path, guildId) => { if (guildId && guildId !== 'global') { await cacheDelPattern(`leaderboard:${guildId}*`).catch(() => {}); diff --git a/src/index.js b/src/index.js index b5e89f32..0a68cc5a 100644 --- a/src/index.js +++ b/src/index.js @@ -43,6 +43,7 @@ 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'; @@ -276,8 +277,7 @@ async function gracefulShutdown(signal) { stopTriage(); stopConversationCleanup(); stopTempbanScheduler(); - stopScheduler(); - stopGithubFeed(); + stopBotStatus(); stopVoiceFlush(); // 1.5. Stop API server (drain in-flight HTTP requests before closing DB) @@ -476,6 +476,9 @@ async function startup() { await loadCommands(); await client.login(token); + // Start bot status/activity rotation (runs 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 new file mode 100644 index 00000000..e6662f4e --- /dev/null +++ b/src/modules/botStatus.js @@ -0,0 +1,220 @@ +/** + * Bot Status Module + * Manages configurable bot presence: status and activity messages. + * + * 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): + * { + * 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 + * } + * + * 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 + */ + +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 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']); + +/** @type {ReturnType | null} */ +let rotateInterval = null; + +/** @type {number} Current activity index in the rotation */ +let currentActivityIndex = 0; + +/** @type {import('discord.js').Client | null} */ +let _client = null; + +/** + * Interpolate variables in an activity text string. + * + * @param {string} text - Activity template string + * @param {import('discord.js').Client} client - Discord client + * @returns {string} Interpolated activity string + */ +export function interpolateActivity(text, client) { + if (!client || typeof text !== 'string') return text; + + 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'; + + return text + .replace(/\{memberCount\}/g, String(memberCount)) + .replace(/\{guildCount\}/g, String(guildCount)) + .replace(/\{botName\}/g, botName); +} + +/** + * Resolve status and activity type from config with safe fallbacks. + * + * @param {Object} cfg - botStatus config section + * @returns {{ status: string, activityType: ActivityType }} Resolved values + */ +export function resolvePresenceConfig(cfg) { + const status = VALID_STATUSES.has(cfg?.status) ? cfg.status : 'online'; + + const typeStr = cfg?.activityType ?? 'Playing'; + const activityType = + ACTIVITY_TYPE_MAP[typeStr] !== undefined ? ACTIVITY_TYPE_MAP[typeStr] : ActivityType.Playing; + + return { status, activityType }; +} + +/** + * Get the active activities list from config. + * Falls back to a sensible default if none configured. + * + * @param {Object} cfg - botStatus config section + * @returns {string[]} Non-empty array of activity strings + */ +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 ['with Discord']; +} + +/** + * Apply the current activity to the Discord client's presence. + * + * @param {import('discord.js').Client} client - Discord client + */ +export function applyPresence(client) { + const globalCfg = getConfig(); + const cfg = globalCfg?.botStatus; + + if (!cfg?.enabled) return; + + const { status, activityType } = resolvePresenceConfig(cfg); + const activities = getActivities(cfg); + + // Guard against empty list after filter + if (activities.length === 0) return; + + // Clamp index to list length + currentActivityIndex = currentActivityIndex % activities.length; + const rawText = activities[currentActivityIndex]; + const name = interpolateActivity(rawText, client); + + try { + client.user.setPresence({ + status, + activities: [{ name, type: activityType }], + }); + + info('Bot presence updated', { + status, + activityType: cfg.activityType ?? 'Playing', + activity: name, + index: currentActivityIndex, + }); + } catch (err) { + warn('Failed to set bot presence', { error: err.message }); + } +} + +/** + * Advance the rotation index and apply presence. + * + * @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); + applyPresence(client); +} + +/** + * Start the bot status rotation. + * Immediately applies the first activity, then rotates on interval. + * + * @param {import('discord.js').Client} client - Discord client + */ +export function startBotStatus(client) { + _client = client; + + const cfg = getConfig()?.botStatus; + if (!cfg?.enabled) { + 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], + }); + } +} + +/** + * Stop the bot status rotation interval. + */ +export function stopBotStatus() { + if (rotateInterval) { + clearInterval(rotateInterval); + rotateInterval = null; + info('Bot status rotation stopped'); + } + _client = null; +} + +/** + * 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) { + startBotStatus(target); + } +} diff --git a/tests/modules/botStatus.test.js b/tests/modules/botStatus.test.js new file mode 100644 index 00000000..f4abbf61 --- /dev/null +++ b/tests/modules/botStatus.test.js @@ -0,0 +1,411 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn(), +})); + +import { ActivityType } from 'discord.js'; +import { + applyPresence, + getActivities, + interpolateActivity, + reloadBotStatus, + resolvePresenceConfig, + startBotStatus, + stopBotStatus, +} from '../../src/modules/botStatus.js'; +import { getConfig } from '../../src/modules/config.js'; + +// ── helpers ──────────────────────────────────────────────────────────────── + +function makeClient({ memberCount = 10, guildCount = 2, username = 'TestBot' } = {}) { + const setPresence = vi.fn(); + const guild = { memberCount }; + return { + user: { username, setPresence: setPresence }, + guilds: { + cache: { + size: guildCount, + reduce: (_fn, initial) => { + // simulate reduce over guilds + let acc = initial; + for (let i = 0; i < guildCount; i++) acc += guild.memberCount; + return acc; + }, + }, + }, + setPresence, + }; +} + +function makeConfig(overrides = {}) { + return { + botStatus: { + enabled: true, + status: 'online', + activityType: 'Playing', + activities: ['with {memberCount} members', 'in {guildCount} servers'], + rotateIntervalMs: 100, + ...overrides, + }, + }; +} + +// ── interpolateActivity ──────────────────────────────────────────────────── + +describe('interpolateActivity', () => { + it('replaces {memberCount} with total member count', () => { + const client = makeClient({ memberCount: 5, guildCount: 2 }); + const result = interpolateActivity('with {memberCount} members', client); + expect(result).toBe('with 10 members'); + }); + + it('replaces {guildCount} with guild count', () => { + const client = makeClient({ guildCount: 3 }); + const result = interpolateActivity('in {guildCount} servers', client); + expect(result).toBe('in 3 servers'); + }); + + it('replaces {botName} with bot username', () => { + const client = makeClient({ username: 'VolvoxBot' }); + const result = interpolateActivity('{botName} here', client); + expect(result).toBe('VolvoxBot here'); + }); + + it('replaces multiple variables in one string', () => { + const client = makeClient({ memberCount: 7, guildCount: 1, username: 'MyBot' }); + const result = interpolateActivity( + '{botName}: {memberCount} members in {guildCount} servers', + client, + ); + expect(result).toBe('MyBot: 7 members in 1 servers'); + }); + + it('returns text unchanged when client is null', () => { + const result = interpolateActivity('hello {memberCount}', null); + expect(result).toBe('hello {memberCount}'); + }); + + it('returns text unchanged when text is not a string', () => { + const client = makeClient(); + const result = interpolateActivity(null, client); + expect(result).toBeNull(); + }); + + it('defaults memberCount to 0 when guilds cache unavailable', () => { + const client = { user: { username: 'Bot' }, guilds: null }; + const result = interpolateActivity('{memberCount}', client); + expect(result).toBe('0'); + }); +}); + +// ── resolvePresenceConfig ────────────────────────────────────────────────── + +describe('resolvePresenceConfig', () => { + it('returns configured status when valid', () => { + const { status } = resolvePresenceConfig({ status: 'idle', activityType: 'Playing' }); + expect(status).toBe('idle'); + }); + + it('falls back to "online" for invalid status', () => { + const { status } = resolvePresenceConfig({ status: 'invalid', activityType: 'Playing' }); + expect(status).toBe('online'); + }); + + it('falls back to "online" when config is null', () => { + const { status } = resolvePresenceConfig(null); + expect(status).toBe('online'); + }); + + it.each(['online', 'idle', 'dnd', 'invisible'])('accepts valid status: %s', (s) => { + const { status } = resolvePresenceConfig({ status: s }); + expect(status).toBe(s); + }); + + it('maps "Playing" to ActivityType.Playing', () => { + const { activityType } = resolvePresenceConfig({ activityType: 'Playing' }); + expect(activityType).toBe(ActivityType.Playing); + }); + + it('maps "Watching" to ActivityType.Watching', () => { + const { activityType } = resolvePresenceConfig({ activityType: 'Watching' }); + expect(activityType).toBe(ActivityType.Watching); + }); + + it('maps "Listening" to ActivityType.Listening', () => { + const { activityType } = resolvePresenceConfig({ activityType: 'Listening' }); + expect(activityType).toBe(ActivityType.Listening); + }); + + it('maps "Competing" to ActivityType.Competing', () => { + const { activityType } = resolvePresenceConfig({ activityType: 'Competing' }); + expect(activityType).toBe(ActivityType.Competing); + }); + + it('maps "Custom" to ActivityType.Custom', () => { + const { activityType } = resolvePresenceConfig({ activityType: 'Custom' }); + expect(activityType).toBe(ActivityType.Custom); + }); + + it('falls back to Playing for unknown activity type', () => { + const { activityType } = resolvePresenceConfig({ activityType: 'Unknown' }); + expect(activityType).toBe(ActivityType.Playing); + }); + + it('falls back to Playing when activityType is missing', () => { + const { activityType } = resolvePresenceConfig({}); + expect(activityType).toBe(ActivityType.Playing); + }); +}); + +// ── getActivities ────────────────────────────────────────────────────────── + +describe('getActivities', () => { + it('returns configured activities array', () => { + const result = getActivities({ activities: ['a', 'b', 'c'] }); + expect(result).toEqual(['a', 'b', 'c']); + }); + + it('filters out empty/whitespace strings', () => { + const result = getActivities({ activities: ['good', '', ' ', 'also good'] }); + expect(result).toEqual(['good', 'also good']); + }); + + it('returns default activity when activities is empty array', () => { + const result = getActivities({ activities: [] }); + expect(result).toEqual(['with Discord']); + }); + + it('returns default activity when activities is missing', () => { + const result = getActivities({}); + expect(result).toEqual(['with Discord']); + }); + + it('returns default activity when cfg is null', () => { + const result = getActivities(null); + expect(result).toEqual(['with Discord']); + }); +}); + +// ── applyPresence ────────────────────────────────────────────────────────── + +describe('applyPresence', () => { + afterEach(() => { + stopBotStatus(); + vi.clearAllMocks(); + }); + + it('calls setPresence with correct status and activity', () => { + const cfg = makeConfig({ activities: ['hello world'] }); + getConfig.mockReturnValue(cfg); + const client = makeClient(); + applyPresence(client); + expect(client.user.setPresence).toHaveBeenCalledWith({ + status: 'online', + activities: [{ name: 'hello world', type: ActivityType.Playing }], + }); + }); + + it('interpolates variables in activity text', () => { + const cfg = makeConfig({ activities: ['with {memberCount} members'] }); + getConfig.mockReturnValue(cfg); + const client = makeClient({ memberCount: 5, guildCount: 2 }); + applyPresence(client); + expect(client.user.setPresence).toHaveBeenCalledWith( + expect.objectContaining({ + activities: [expect.objectContaining({ name: 'with 10 members' })], + }), + ); + }); + + it('does nothing when botStatus.enabled is false', () => { + getConfig.mockReturnValue(makeConfig({ enabled: false })); + const client = makeClient(); + applyPresence(client); + expect(client.user.setPresence).not.toHaveBeenCalled(); + }); + + it('does nothing when botStatus config is missing', () => { + getConfig.mockReturnValue({}); + const client = makeClient(); + applyPresence(client); + expect(client.user.setPresence).not.toHaveBeenCalled(); + }); + + it('uses dnd status when configured', () => { + getConfig.mockReturnValue(makeConfig({ status: 'dnd', activities: ['busy'] })); + const client = makeClient(); + applyPresence(client); + expect(client.user.setPresence).toHaveBeenCalledWith( + expect.objectContaining({ status: 'dnd' }), + ); + }); + + 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(() => { + throw new Error('Network error'); + }); + expect(() => applyPresence(client)).not.toThrow(); + expect(warn).toHaveBeenCalled(); + }); +}); + +// ── startBotStatus / stopBotStatus ───────────────────────────────────────── + +describe('startBotStatus / stopBotStatus', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + stopBotStatus(); + vi.useRealTimers(); + }); + + it('calls setPresence immediately on start', () => { + getConfig.mockReturnValue(makeConfig({ activities: ['hello'] })); + const client = makeClient(); + startBotStatus(client); + expect(client.user.setPresence).toHaveBeenCalledTimes(1); + }); + + it('rotates activities on interval', () => { + const cfg = makeConfig({ + activities: ['activity A', 'activity B', 'activity C'], + rotateIntervalMs: 100, + }); + getConfig.mockReturnValue(cfg); + const client = makeClient(); + + startBotStatus(client); + expect(client.user.setPresence).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(100); + expect(client.user.setPresence).toHaveBeenCalledTimes(2); + + vi.advanceTimersByTime(100); + expect(client.user.setPresence).toHaveBeenCalledTimes(3); + }); + + it('does not start interval for single activity', () => { + getConfig.mockReturnValue(makeConfig({ activities: ['only one'], rotateIntervalMs: 100 })); + const client = makeClient(); + startBotStatus(client); + + vi.advanceTimersByTime(500); + // Only the initial call — no rotation + expect(client.user.setPresence).toHaveBeenCalledTimes(1); + }); + + it('stops rotation on stopBotStatus', () => { + const cfg = makeConfig({ activities: ['A', 'B'], rotateIntervalMs: 100 }); + getConfig.mockReturnValue(cfg); + const client = makeClient(); + + startBotStatus(client); + vi.advanceTimersByTime(100); + expect(client.user.setPresence).toHaveBeenCalledTimes(2); + + stopBotStatus(); + vi.advanceTimersByTime(500); + // No new calls after stop + expect(client.user.setPresence).toHaveBeenCalledTimes(2); + }); + + it('does nothing when disabled', () => { + getConfig.mockReturnValue(makeConfig({ enabled: false })); + const client = makeClient(); + startBotStatus(client); + vi.advanceTimersByTime(500); + expect(client.user.setPresence).not.toHaveBeenCalled(); + }); + + it('uses default interval of 30s when rotateIntervalMs is missing', () => { + const cfg = makeConfig({ activities: ['A', 'B'], rotateIntervalMs: undefined }); + getConfig.mockReturnValue(cfg); + const client = makeClient(); + + startBotStatus(client); + vi.advanceTimersByTime(29_999); + expect(client.user.setPresence).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(1); + expect(client.user.setPresence).toHaveBeenCalledTimes(2); + }); + + it('wraps activity index around when activities cycle through', () => { + const cfg = makeConfig({ + activities: ['first', 'second'], + rotateIntervalMs: 100, + }); + getConfig.mockReturnValue(cfg); + const client = makeClient(); + + startBotStatus(client); + // Initial: first + const calls = client.user.setPresence.mock.calls; + expect(calls[0][0].activities[0].name).toBe('first'); + + vi.advanceTimersByTime(100); + expect(calls[1][0].activities[0].name).toBe('second'); + + vi.advanceTimersByTime(100); + // Wraps back to first + expect(calls[2][0].activities[0].name).toBe('first'); + }); +}); + +// ── reloadBotStatus ──────────────────────────────────────────────────────── + +describe('reloadBotStatus', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + stopBotStatus(); + vi.useRealTimers(); + }); + + it('restarts with updated config', () => { + const cfg1 = makeConfig({ activities: ['old activity'], rotateIntervalMs: 100 }); + const cfg2 = makeConfig({ + activities: ['new activity 1', 'new activity 2'], + rotateIntervalMs: 100, + }); + + getConfig.mockReturnValue(cfg1); + const client = makeClient(); + startBotStatus(client); + + expect(client.user.setPresence.mock.calls[0][0].activities[0].name).toBe('old activity'); + + // Update config and reload + getConfig.mockReturnValue(cfg2); + reloadBotStatus(client); + + const lastCall = client.user.setPresence.mock.calls.at(-1); + expect(lastCall[0].activities[0].name).toBe('new activity 1'); + }); + + it('uses cached client when none is provided', () => { + getConfig.mockReturnValue(makeConfig({ activities: ['hello'] })); + const client = makeClient(); + startBotStatus(client); + + const callsBefore = client.user.setPresence.mock.calls.length; + reloadBotStatus(); // no client arg + expect(client.user.setPresence.mock.calls.length).toBeGreaterThan(callsBefore); + }); +});