From deae296812bceb400c1fc6f0a6f482785cd3e343 Mon Sep 17 00:00:00 2001 From: MohsinCoding Date: Sat, 14 Mar 2026 16:40:14 -0400 Subject: [PATCH 01/14] feat(bot-status): add configurable rotating presence with templates --- config.json | 22 ++ src/api/utils/configValidation.js | 27 ++ src/config-listeners.js | 4 + src/index.js | 5 + src/modules/botStatus.js | 313 ++++++++++++++---- tests/api/utils/configAllowlist.test.js | 2 + tests/api/utils/configValidation.test.js | 33 +- tests/api/utils/validateConfigPatch.test.js | 2 +- tests/config-listeners.test.js | 7 +- tests/modules/botStatus.test.js | 60 ++++ .../config-workspace/config-categories.ts | 14 +- .../dashboard/config-workspace/types.ts | 3 +- web/src/types/config.ts | 21 +- 13 files changed, 434 insertions(+), 79 deletions(-) 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/utils/configValidation.js b/src/api/utils/configValidation.js index 402587313..ab132bc4c 100644 --- a/src/api/utils/configValidation.js +++ b/src/api/utils/configValidation.js @@ -177,6 +177,33 @@ 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', + required: ['text'], + }, + }, + }, + }, + }, + }, reminders: { type: 'object', properties: { 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..5cabc6721 100644 --- a/src/index.js +++ b/src/index.js @@ -46,6 +46,7 @@ import { loadConfig } from './modules/config.js'; import { registerEventHandlers } from './modules/events.js'; import { startGithubFeed, stopGithubFeed } from './modules/githubFeed.js'; +import { startBotStatus, stopBotStatus } from './modules/botStatus.js'; import { checkMem0Health, markUnavailable } from './modules/memory.js'; import { startTempbanScheduler, stopTempbanScheduler } from './modules/moderation.js'; import { loadOptOuts } from './modules/optout.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 { @@ -367,6 +369,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 e6662f4e2..3f7cf33be 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,167 @@ 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 +283,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 +321,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 +333,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 +377,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/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..1b35b9026 100644 --- a/tests/api/utils/configValidation.test.js +++ b/tests/api/utils/configValidation.test.js @@ -104,7 +104,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', + ]), ); }); }); @@ -214,6 +222,29 @@ describe('configValidation', () => { // channelModes has openProperties — any channel-ID sub-key is dynamic; // the value is validated against the parent object schema, so an object passes expect(validateSingleValue('ai.channelModes.12345', { mode: 'vibe' })).toEqual([]); + + 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); }); }); diff --git a/tests/api/utils/validateConfigPatch.test.js b/tests/api/utils/validateConfigPatch.test.js index 510c34e14..c347d68ab 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/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/modules/botStatus.test.js b/tests/modules/botStatus.test.js index f4abbf616..d918d6718 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/web/src/components/dashboard/config-workspace/config-categories.ts b/web/src/components/dashboard/config-workspace/config-categories.ts index fae1a986b..c12ed2615 100644 --- a/web/src/components/dashboard/config-workspace/config-categories.ts +++ b/web/src/components/dashboard/config-workspace/config-categories.ts @@ -36,8 +36,8 @@ 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 +68,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 +351,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/types/config.ts b/web/src/types/config.ts index 75fb8f3dd..c784b5720 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. From 42f462e10f59c2c304407bdf80b27ef428e429f2 Mon Sep 17 00:00:00 2001 From: MohsinCoding Date: Sat, 14 Mar 2026 16:44:11 -0400 Subject: [PATCH 02/14] chore: apply lint formatting updates --- src/index.js | 4 +- src/modules/botStatus.js | 7 +- src/modules/events/messageCreate.js | 2 +- tests/api/routes/warnings.test.js | 4 +- tests/api/utils/configValidation.test.js | 2 +- tests/commands/voice.test.js | 4 +- tests/utils/discordCache.test.js | 4 +- web/src/app/page.tsx | 26 ++++- web/src/components/landing/FeatureGrid.tsx | 10 +- web/src/components/landing/Footer.tsx | 22 ++-- web/src/components/landing/Hero.tsx | 124 ++++++++++++++++----- web/src/components/landing/Pricing.tsx | 16 ++- web/src/components/landing/Stats.tsx | 75 +++++++++++-- 13 files changed, 226 insertions(+), 74 deletions(-) diff --git a/src/index.js b/src/index.js index 5cabc6721..ddf2246d4 100644 --- a/src/index.js +++ b/src/index.js @@ -42,11 +42,11 @@ import { startConversationCleanup, stopConversationCleanup, } from './modules/ai.js'; -import { loadConfig } from './modules/config.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 { startBotStatus, stopBotStatus } from './modules/botStatus.js'; import { checkMem0Health, markUnavailable } from './modules/memory.js'; import { startTempbanScheduler, stopTempbanScheduler } from './modules/moderation.js'; import { loadOptOuts } from './modules/optout.js'; diff --git a/src/modules/botStatus.js b/src/modules/botStatus.js index 3f7cf33be..7998c4fa5 100644 --- a/src/modules/botStatus.js +++ b/src/modules/botStatus.js @@ -145,7 +145,9 @@ export function resolvePresenceStatus(cfg) { */ export function resolveActivityType(typeStr) { if (!typeStr) return ActivityType.Playing; - return ACTIVITY_TYPE_MAP[typeStr] !== undefined ? ACTIVITY_TYPE_MAP[typeStr] : ActivityType.Playing; + return ACTIVITY_TYPE_MAP[typeStr] !== undefined + ? ACTIVITY_TYPE_MAP[typeStr] + : ActivityType.Playing; } /** @@ -182,7 +184,8 @@ function normalizeMessage(entry, fallbackType) { 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'; + const type = + typeof entry.type === 'string' && entry.type.trim() ? entry.type : (fallbackType ?? 'Playing'); return { type, text: rawText }; } 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/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/configValidation.test.js b/tests/api/utils/configValidation.test.js index 1b35b9026..7612d7eac 100644 --- a/tests/api/utils/configValidation.test.js +++ b/tests/api/utils/configValidation.test.js @@ -244,7 +244,7 @@ describe('configValidation', () => { 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); + expect(errors.some((e) => e.includes('missing required key "text"'))).toBe(true); }); }); 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/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..9f934bb4b 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/landing/FeatureGrid.tsx b/web/src/components/landing/FeatureGrid.tsx index 42b5cb14f..fa3bf058b 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 5487ac10c..6f5d6f6e1 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 +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 833df5e8c..993f87c89 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}
From e42144e75ea70a68d1a9e805fc8916b2f35699e1 Mon Sep 17 00:00:00 2001 From: MohsinCoding Date: Wed, 18 Mar 2026 23:32:18 -0400 Subject: [PATCH 03/14] fix: harden bot status validation and fallbacks --- src/api/utils/configValidation.js | 53 ++++++------ src/modules/botStatus.js | 103 ++++++++++++++++++++--- tests/api/utils/configValidation.test.js | 45 ++++++++++ tests/modules/botStatus.test.js | 63 +++++++++++++- 4 files changed, 226 insertions(+), 38 deletions(-) diff --git a/src/api/utils/configValidation.js b/src/api/utils/configValidation.js index ab132bc4c..3ea0f81a1 100644 --- a/src/api/utils/configValidation.js +++ b/src/api/utils/configValidation.js @@ -197,6 +197,13 @@ export const CONFIG_SCHEMA = { type: 'array', items: { type: 'object', + properties: { + type: { + type: 'string', + enum: ['Playing', 'Watching', 'Listening', 'Competing', 'Streaming', 'Custom'], + }, + text: { type: 'string', minLength: 1 }, + }, required: ['text'], }, }, @@ -278,6 +285,9 @@ 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}"`); } @@ -303,24 +313,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; @@ -329,14 +322,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/modules/botStatus.js b/src/modules/botStatus.js index 7998c4fa5..5a80d8640 100644 --- a/src/modules/botStatus.js +++ b/src/modules/botStatus.js @@ -51,6 +51,8 @@ const VALID_STATUSES = new Set(['online', 'idle', 'dnd', 'invisible']); const DEFAULT_LEGACY_ROTATE_INTERVAL_MS = 30_000; const DEFAULT_ROTATE_INTERVAL_MINUTES = 5; +const DEFAULT_ACTIVITY_TYPE = 'Playing'; +const DEFAULT_ACTIVITY_TEXT = 'with Discord'; /** @type {ReturnType | null} */ let rotateInterval = null; @@ -101,6 +103,37 @@ function getPackageVersion() { 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. * @@ -145,9 +178,15 @@ export function resolvePresenceStatus(cfg) { */ export function resolveActivityType(typeStr) { if (!typeStr) return ActivityType.Playing; - return ACTIVITY_TYPE_MAP[typeStr] !== undefined - ? ACTIVITY_TYPE_MAP[typeStr] - : 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; } /** @@ -168,24 +207,48 @@ export function resolvePresenceConfig(cfg) { * * @param {unknown} entry * @param {string | undefined} fallbackType + * @param {string} source * @returns {{type: string, text: string} | null} */ -function normalizeMessage(entry, fallbackType) { +function normalizeMessage(entry, fallbackType, source) { + const resolvedFallbackType = resolveFallbackType(fallbackType, `${source}.fallbackType`); + if (typeof entry === 'string') { const text = entry.trim(); - if (!text) return null; - return { type: fallbackType ?? 'Playing', text }; + 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: Array.isArray(entry) ? 'array' : entry === null ? 'null' : typeof entry, + }); return null; } const rawText = typeof entry.text === 'string' ? entry.text.trim() : ''; - if (!rawText) return null; + if (!rawText) { + warn('Ignoring bot status message without valid text', { source }); + return null; + } + + 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, + }); + } + } - const type = - typeof entry.type === 'string' && entry.type.trim() ? entry.type : (fallbackType ?? 'Playing'); return { type, text: rawText }; } @@ -199,24 +262,40 @@ export function getRotationMessages(cfg) { const rotationMessages = cfg?.rotation?.messages; if (Array.isArray(rotationMessages)) { const normalized = rotationMessages - .map((entry) => normalizeMessage(entry, cfg?.activityType)) + .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) => normalizeMessage(entry, cfg?.activityType)) + .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: 'Playing', text: 'with Discord' }]; + return [{ type: DEFAULT_ACTIVITY_TYPE, text: DEFAULT_ACTIVITY_TEXT }]; } /** diff --git a/tests/api/utils/configValidation.test.js b/tests/api/utils/configValidation.test.js index 7612d7eac..d2c9a68d8 100644 --- a/tests/api/utils/configValidation.test.js +++ b/tests/api/utils/configValidation.test.js @@ -36,6 +36,30 @@ 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', () => { @@ -246,6 +270,27 @@ describe('configValidation', () => { 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', () => { diff --git a/tests/modules/botStatus.test.js b/tests/modules/botStatus.test.js index d918d6718..0b60ada7f 100644 --- a/tests/modules/botStatus.test.js +++ b/tests/modules/botStatus.test.js @@ -23,6 +23,7 @@ import { stopBotStatus, } from '../../src/modules/botStatus.js'; import { getConfig } from '../../src/modules/config.js'; +import { warn } from '../../src/logger.js'; // ── helpers ──────────────────────────────────────────────────────────────── @@ -117,6 +118,7 @@ describe('interpolateActivity', () => { it('replaces {version} from package.json', () => { const client = makeClient(); const result = interpolateActivity('v{version}', client); + expect(result).not.toContain('{version}'); expect(result).toMatch(/^v.+/); }); }); @@ -210,6 +212,10 @@ describe('getActivities', () => { }); describe('getRotationMessages / resolveRotationIntervalMs', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + it('uses rotation.messages when configured', () => { const messages = getRotationMessages({ rotation: { @@ -229,6 +235,62 @@ describe('getRotationMessages / resolveRotationIntervalMs', () => { const intervalMs = resolveRotationIntervalMs({ rotation: { intervalMinutes: 5 } }); expect(intervalMs).toBe(300_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 ────────────────────────────────────────────────────────── @@ -286,7 +348,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(() => { From 7697739c932d5c65f055a5c74518b71b336fdea2 Mon Sep 17 00:00:00 2001 From: MohsinCoding Date: Wed, 18 Mar 2026 23:41:34 -0400 Subject: [PATCH 04/14] fix: align bot status runtime and config scope --- src/api/routes/guilds.js | 12 +++++++++--- src/index.js | 6 +++--- src/modules/botStatus.js | 5 +++-- tests/api/routes/guilds.test.js | 29 +++++++++++++++++++++++++++++ tests/index.test.js | 21 +++++++++++++++++++++ tests/modules/botStatus.test.js | 32 +++++++++++++++++++++----------- 6 files changed, 86 insertions(+), 19 deletions(-) diff --git a/src/api/routes/guilds.js b/src/api/routes/guilds.js index 9487e4110..659e96436 100644 --- a/src/api/routes/guilds.js +++ b/src/api/routes/guilds.js @@ -740,14 +740,20 @@ router.patch('/:id/config', requireGuildAdmin, validateGuild, async (req, res) = } const { path, value, topLevelKey } = result; + const writeScope = topLevelKey === 'botStatus' ? '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/index.js b/src/index.js index ddf2246d4..8cf0cc733 100644 --- a/src/index.js +++ b/src/index.js @@ -369,9 +369,6 @@ 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); @@ -384,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 5a80d8640..403f81602 100644 --- a/src/modules/botStatus.js +++ b/src/modules/botStatus.js @@ -51,6 +51,7 @@ 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'; @@ -316,11 +317,11 @@ export function getActivities(cfg) { */ export function resolveRotationIntervalMs(cfg) { if (typeof cfg?.rotation?.intervalMinutes === 'number' && cfg.rotation.intervalMinutes > 0) { - return Math.round(cfg.rotation.intervalMinutes * 60_000); + return Math.max(Math.round(cfg.rotation.intervalMinutes * 60_000), MIN_PRESENCE_INTERVAL_MS); } if (typeof cfg?.rotateIntervalMs === 'number' && cfg.rotateIntervalMs > 0) { - return cfg.rotateIntervalMs; + return Math.max(cfg.rotateIntervalMs, MIN_PRESENCE_INTERVAL_MS); } if (cfg?.rotation) { 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/index.test.js b/tests/index.test.js index 8ee9e1897..a1f9d2b29 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 }); diff --git a/tests/modules/botStatus.test.js b/tests/modules/botStatus.test.js index 0b60ada7f..c875ddb9f 100644 --- a/tests/modules/botStatus.test.js +++ b/tests/modules/botStatus.test.js @@ -236,6 +236,16 @@ describe('getRotationMessages / resolveRotationIntervalMs', () => { 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', @@ -381,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(); @@ -389,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); }); @@ -407,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); }); @@ -445,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(); @@ -455,10 +465,10 @@ 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'); }); @@ -468,7 +478,7 @@ describe('startBotStatus / stopBotStatus', () => { activities: undefined, rotation: { enabled: true, - intervalMinutes: 0.001, // 60ms for test speed + intervalMinutes: 0.001, messages: [ { type: 'Watching', text: 'first' }, { type: 'Playing', text: 'second' }, @@ -481,7 +491,7 @@ describe('startBotStatus / stopBotStatus', () => { startBotStatus(client); expect(client.user.setPresence.mock.calls[0][0].activities[0].name).toBe('first'); - vi.advanceTimersByTime(60); + vi.advanceTimersByTime(20_000); expect(client.user.setPresence.mock.calls[1][0].activities[0].name).toBe('second'); }); }); From 23de125f9bedcdeaa419fe9e1d4e4feebbf3eeef Mon Sep 17 00:00:00 2001 From: MohsinCoding Date: Wed, 18 Mar 2026 23:53:47 -0400 Subject: [PATCH 05/14] chore: apply lint cleanup after merge --- biome.json | 2 +- src/modules/botStatus.js | 7 +- src/modules/events.js | 2 +- src/modules/events/interactionCreate.js | 10 +- src/modules/handlers/ticketHandler.js | 4 +- tests/api/utils/configValidation.test.js | 7 +- tests/modules/botStatus.test.js | 2 +- tests/modules/events-extra.test.js | 71 ++++++++------ .../CommunitySettingsSection.tsx | 93 ++++++++++++++++++- web/src/components/dashboard/types.ts | 5 +- 10 files changed, 159 insertions(+), 44 deletions(-) diff --git a/biome.json b/biome.json index fd9864302..133a6ff3d 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.6/schema.json", "files": { "includes": [ "src/**/*.js", diff --git a/src/modules/botStatus.js b/src/modules/botStatus.js index 403f81602..baa58ff0f 100644 --- a/src/modules/botStatus.js +++ b/src/modules/botStatus.js @@ -273,9 +273,10 @@ export function getRotationMessages(cfg) { 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', + fallback: + Array.isArray(cfg?.activities) && cfg.activities.length > 0 + ? 'botStatus.activities' + : 'default', }); } } diff --git a/src/modules/events.js b/src/modules/events.js index ae2ba6d92..adc7ca746 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, 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/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/tests/api/utils/configValidation.test.js b/tests/api/utils/configValidation.test.js index d2c9a68d8..227c1ad74 100644 --- a/tests/api/utils/configValidation.test.js +++ b/tests/api/utils/configValidation.test.js @@ -57,8 +57,9 @@ describe('configValidation', () => { 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'); + expect( + validateValue([{ type: 'Watching', text: 'ready', extra: true }], schema, 'test')[0], + ).toContain('unknown config key'); }); }); @@ -246,6 +247,8 @@ describe('configValidation', () => { // channelModes has openProperties — any channel-ID sub-key is dynamic; // the value is validated against the parent object schema, so an object passes expect(validateSingleValue('ai.channelModes.12345', { mode: 'vibe' })).toEqual([]); + }); + }); describe('botStatus schema validation', () => { it('should accept valid botStatus rotation settings', () => { diff --git a/tests/modules/botStatus.test.js b/tests/modules/botStatus.test.js index c875ddb9f..a2cabf580 100644 --- a/tests/modules/botStatus.test.js +++ b/tests/modules/botStatus.test.js @@ -11,6 +11,7 @@ vi.mock('../../src/modules/config.js', () => ({ })); import { ActivityType } from 'discord.js'; +import { warn } from '../../src/logger.js'; import { applyPresence, getActivities, @@ -23,7 +24,6 @@ import { stopBotStatus, } from '../../src/modules/botStatus.js'; import { getConfig } from '../../src/modules/config.js'; -import { warn } from '../../src/logger.js'; // ── helpers ──────────────────────────────────────────────────────────────── 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/web/src/components/dashboard/config-sections/CommunitySettingsSection.tsx b/web/src/components/dashboard/config-sections/CommunitySettingsSection.tsx index 1329d04f3..ce5f1e8f1 100644 --- a/web/src/components/dashboard/config-sections/CommunitySettingsSection.tsx +++ b/web/src/components/dashboard/config-sections/CommunitySettingsSection.tsx @@ -130,6 +130,95 @@ 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) => ({ + ...prev, + botStatus: { + ...prev.botStatus, + rotation: { ...prev.botStatus?.rotation, enabled: value }, + }, + })) + } + 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/types.ts b/web/src/components/dashboard/types.ts index d8e4d8986..80928a2a4 100644 --- a/web/src/components/dashboard/types.ts +++ b/web/src/components/dashboard/types.ts @@ -68,7 +68,10 @@ export function validateBotHealth(value: unknown): string | null { if (!isObject(value.system)) return 'missing system'; if (typeof value.system.nodeVersion !== 'string') return 'invalid system.nodeVersion'; if (!isObject(value.system.cpuUsage)) return 'missing system.cpuUsage'; - if (typeof value.system.cpuUsage.user !== 'number' || typeof value.system.cpuUsage.system !== 'number') + if ( + typeof value.system.cpuUsage.user !== 'number' || + typeof value.system.cpuUsage.system !== 'number' + ) return 'invalid system.cpuUsage fields'; if (!Array.isArray(value.restarts)) return 'missing restarts'; From 2a075bfc193f73306c5536a4ead09d46c31b248b Mon Sep 17 00:00:00 2001 From: Bill Chirico Date: Thu, 19 Mar 2026 17:22:03 -0400 Subject: [PATCH 06/14] fix(web): optional types, quote consistency, docs URL, pricing readability Co-Authored-By: Claude Opus 4.6 (1M context) --- web/src/app/page.tsx | 14 +++++++------- web/src/components/landing/Pricing.tsx | 8 ++++++-- web/src/types/config.ts | 10 +++++----- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 9f934bb4b..1efee0ad6 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -94,20 +94,20 @@ export default function LandingPage() { Close menu ) : ( Open menu )} @@ -142,7 +142,7 @@ export default function LandingPage() { Pricing Docs diff --git a/web/src/components/landing/Pricing.tsx b/web/src/components/landing/Pricing.tsx index 913aa70e6..4c7bec033 100644 --- a/web/src/components/landing/Pricing.tsx +++ b/web/src/components/landing/Pricing.tsx @@ -79,7 +79,9 @@ export function Pricing() { {/* Toggle */}
Monthly @@ -98,7 +100,9 @@ export function Pricing() { /> Annual Save 36% diff --git a/web/src/types/config.ts b/web/src/types/config.ts index c784b5720..19cae8f1b 100644 --- a/web/src/types/config.ts +++ b/web/src/types/config.ts @@ -247,20 +247,20 @@ export interface ToggleSectionConfig { } export interface BotStatusRotationMessage { - type: 'Playing' | 'Watching' | 'Listening' | 'Competing' | 'Streaming' | 'Custom'; + type?: 'Playing' | 'Watching' | 'Listening' | 'Competing' | 'Streaming' | 'Custom'; text: string; } export interface BotStatusRotationConfig { - enabled: boolean; - intervalMinutes: number; - messages: BotStatusRotationMessage[]; + enabled?: boolean; + intervalMinutes?: number; + messages?: BotStatusRotationMessage[]; } export interface BotStatusConfig { enabled: boolean; status: 'online' | 'idle' | 'dnd' | 'invisible'; - rotation: BotStatusRotationConfig; + rotation?: BotStatusRotationConfig; } /** TL;DR summary feature settings. */ From 62c094b3ffd5c3fa84b3cb95ec46ba3126b4ecf7 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Thu, 19 Mar 2026 17:29:53 -0400 Subject: [PATCH 07/14] fix: address review comments (interval safety, validation, string ops) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clamp rotation interval to MIN_PRESENCE_INTERVAL_MS (20s) - Fix cross-format fallback order in resolveRotationIntervalMs - Use replaceAll() instead of regex .replace(/…/g) - Extract getEntryTypeLabel() to reduce cognitive complexity - Add pattern validation for whitespace-only text rejection - Document global botStatus scope escalation intent --- src/api/routes/guilds.js | 2 ++ src/api/utils/configValidation.js | 5 ++++- src/modules/botStatus.js | 35 +++++++++++++++++++++---------- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/api/routes/guilds.js b/src/api/routes/guilds.js index 659e96436..bdfb01e0a 100644 --- a/src/api/routes/guilds.js +++ b/src/api/routes/guilds.js @@ -740,6 +740,8 @@ router.patch('/:id/config', requireGuildAdmin, validateGuild, async (req, res) = } const { path, value, topLevelKey } = result; + // botStatus is global (not per-guild) — in single-tenant deployments any guild admin + // may configure it. For multi-tenant scenarios, add bot-owner auth here. const writeScope = topLevelKey === 'botStatus' ? 'global' : req.params.id; try { diff --git a/src/api/utils/configValidation.js b/src/api/utils/configValidation.js index 3ea0f81a1..9191594de 100644 --- a/src/api/utils/configValidation.js +++ b/src/api/utils/configValidation.js @@ -202,7 +202,7 @@ export const CONFIG_SCHEMA = { type: 'string', enum: ['Playing', 'Watching', 'Listening', 'Competing', 'Streaming', 'Custom'], }, - text: { type: 'string', minLength: 1 }, + text: { type: 'string', minLength: 1, pattern: '\\S' }, }, required: ['text'], }, @@ -294,6 +294,9 @@ export function validateValue(value, schema, path) { 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': diff --git a/src/modules/botStatus.js b/src/modules/botStatus.js index baa58ff0f..e693ccad7 100644 --- a/src/modules/botStatus.js +++ b/src/modules/botStatus.js @@ -153,12 +153,12 @@ export function interpolateActivity(text, client) { const version = getPackageVersion(); return text - .replace(/\{memberCount\}/g, String(memberCount)) - .replace(/\{guildCount\}/g, String(guildCount)) - .replace(/\{botName\}/g, botName) - .replace(/\{commandCount\}/g, String(commandCount)) - .replace(/\{uptime\}/g, uptime) - .replace(/\{version\}/g, version); + .replaceAll('{memberCount}', String(memberCount)) + .replaceAll('{guildCount}', String(guildCount)) + .replaceAll('{botName}', botName) + .replaceAll('{commandCount}', String(commandCount)) + .replaceAll('{uptime}', uptime) + .replaceAll('{version}', version); } /** @@ -211,6 +211,17 @@ export function resolvePresenceConfig(cfg) { * @param {string} source * @returns {{type: string, text: string} | null} */ +/** + * 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; +} + function normalizeMessage(entry, fallbackType, source) { const resolvedFallbackType = resolveFallbackType(fallbackType, `${source}.fallbackType`); @@ -226,7 +237,7 @@ function normalizeMessage(entry, fallbackType, source) { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { warn('Ignoring invalid bot status message entry', { source, - entryType: Array.isArray(entry) ? 'array' : entry === null ? 'null' : typeof entry, + entryType: getEntryTypeLabel(entry), }); return null; } @@ -321,14 +332,16 @@ export function resolveRotationIntervalMs(cfg) { return Math.max(Math.round(cfg.rotation.intervalMinutes * 60_000), MIN_PRESENCE_INTERVAL_MS); } - if (typeof cfg?.rotateIntervalMs === 'number' && cfg.rotateIntervalMs > 0) { - return Math.max(cfg.rotateIntervalMs, MIN_PRESENCE_INTERVAL_MS); - } - + // 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; } From 0019f4a4258e5eabe7534de3124e06be44c52efd Mon Sep 17 00:00:00 2001 From: Pip Build Date: Thu, 19 Mar 2026 17:30:45 -0400 Subject: [PATCH 08/14] fix: remove unused getConfig import from index.js --- src/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.js b/src/index.js index 8cf0cc733..4b88beac3 100644 --- a/src/index.js +++ b/src/index.js @@ -43,7 +43,7 @@ import { stopConversationCleanup, } from './modules/ai.js'; import { startBotStatus, stopBotStatus } from './modules/botStatus.js'; -import { getConfig, loadConfig } from './modules/config.js'; +import { loadConfig } from './modules/config.js'; import { registerEventHandlers } from './modules/events.js'; import { startGithubFeed, stopGithubFeed } from './modules/githubFeed.js'; From a3a204511e65ba823fb9ed1e83b94a3ae80041b5 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Thu, 19 Mar 2026 18:09:44 -0400 Subject: [PATCH 09/14] fix(web): landing page lint and formatting fixes --- web/src/components/landing/FeatureGrid.tsx | 8 ++++-- web/src/components/landing/Footer.tsx | 4 +-- web/src/components/landing/Stats.tsx | 29 ++++++++++++++-------- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/web/src/components/landing/FeatureGrid.tsx b/web/src/components/landing/FeatureGrid.tsx index fa3bf058b..b9ea22348 100644 --- a/web/src/components/landing/FeatureGrid.tsx +++ b/web/src/components/landing/FeatureGrid.tsx @@ -61,8 +61,12 @@ 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 6f5d6f6e1..3eaf00bb6 100644 --- a/web/src/components/landing/Footer.tsx +++ b/web/src/components/landing/Footer.tsx @@ -23,7 +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 @@ -101,7 +101,7 @@ export function Footer() { className="pt-8 border-t border-border" >

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

diff --git a/web/src/components/landing/Stats.tsx b/web/src/components/landing/Stats.tsx index 993f87c89..154834973 100644 --- a/web/src/components/landing/Stats.tsx +++ b/web/src/components/landing/Stats.tsx @@ -85,8 +85,14 @@ function SkeletonCard() {

@@ -101,13 +107,13 @@ 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({ @@ -307,7 +313,10 @@ export function Stats() { >
{card.icon}
From 41f1cf34e3c3f224802fddce0b39b7be73fc66ae Mon Sep 17 00:00:00 2001 From: Pip Build Date: Thu, 19 Mar 2026 18:09:44 -0400 Subject: [PATCH 10/14] fix: biome schema, JSX quotes, and line width fixes --- biome.json | 2 +- src/modules/handlers/ticketHandler.js | 4 +++- web/src/app/page.tsx | 6 +++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/biome.json b/biome.json index 133a6ff3d..559da52c8 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.6/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json", "files": { "includes": [ "src/**/*.js", diff --git a/src/modules/handlers/ticketHandler.js b/src/modules/handlers/ticketHandler.js index 43916e30c..233193f79 100644 --- a/src/modules/handlers/ticketHandler.js +++ b/src/modules/handlers/ticketHandler.js @@ -38,7 +38,9 @@ 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/web/src/app/page.tsx b/web/src/app/page.tsx index 1efee0ad6..e7e173ed5 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -94,10 +94,10 @@ export default function LandingPage() { Close menu ) : ( From b092c86c5ac2a866054ea2b3c9f7ddc49c6fb521 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Thu, 19 Mar 2026 18:10:56 -0400 Subject: [PATCH 11/14] fix: restrict botStatus to bot owners, fix streaming/presence/normalize --- src/api/routes/guilds.js | 9 ++++++--- src/modules/botStatus.js | 42 +++++++++++++++++++++++++++------------- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/api/routes/guilds.js b/src/api/routes/guilds.js index bdfb01e0a..6235ce961 100644 --- a/src/api/routes/guilds.js +++ b/src/api/routes/guilds.js @@ -740,9 +740,12 @@ router.patch('/:id/config', requireGuildAdmin, validateGuild, async (req, res) = } const { path, value, topLevelKey } = result; - // botStatus is global (not per-guild) — in single-tenant deployments any guild admin - // may configure it. For multi-tenant scenarios, add bot-owner auth here. - const writeScope = topLevelKey === 'botStatus' ? 'global' : req.params.id; + // 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, writeScope === 'global' ? undefined : req.params.id); diff --git a/src/modules/botStatus.js b/src/modules/botStatus.js index e693ccad7..06eb2db23 100644 --- a/src/modules/botStatus.js +++ b/src/modules/botStatus.js @@ -36,13 +36,18 @@ import { getConfig } from './config.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); -/** Map Discord activity type strings to ActivityType enum values */ +/** + * 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, }; @@ -203,14 +208,6 @@ export function resolvePresenceConfig(cfg) { }; } -/** - * Normalize a configured status message entry. - * - * @param {unknown} entry - * @param {string | undefined} fallbackType - * @param {string} source - * @returns {{type: string, text: string} | null} - */ /** * Determine the type label for an entry for logging purposes. * @param {unknown} entry @@ -222,6 +219,14 @@ function getEntryTypeLabel(entry) { 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`); @@ -417,9 +422,7 @@ export function applyPresence(client) { * @param {import('discord.js').Client} client - Discord client */ function rotate(client) { - const cfg = getConfig()?.botStatus; - const messages = getRotationMessages(cfg); - currentActivityIndex = (currentActivityIndex + 1) % Math.max(messages.length, 1); + currentActivityIndex += 1; applyPresence(client); } @@ -476,6 +479,8 @@ export function stopBotStatus() { /** * 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) */ @@ -483,6 +488,17 @@ export function reloadBotStatus(client) { 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); } } From 9c4f0111b3245efea3f5430c7558d2ea70b4b985 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Thu, 19 Mar 2026 18:11:38 -0400 Subject: [PATCH 12/14] test: import production SAFE_CONFIG_KEYS and cover shutdown --- tests/api/utils/validateConfigPatch.test.js | 5 ++--- tests/index.test.js | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/api/utils/validateConfigPatch.test.js b/tests/api/utils/validateConfigPatch.test.js index c347d68ab..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', 'botStatus']); - 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/index.test.js b/tests/index.test.js index a1f9d2b29..a26c74073 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -447,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 () => { From 9f6a262f0288eac0091c0eff1fe996e8a4e4f5b1 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Thu, 19 Mar 2026 18:11:54 -0400 Subject: [PATCH 13/14] fix(web): dashboard legacy compat, types, and formatting --- .../CommunitySettingsSection.tsx | 34 ++++++++++++------- .../config-workspace/config-categories.ts | 10 +++++- web/src/components/dashboard/types.ts | 5 +-- web/src/types/config.ts | 10 ++++-- 4 files changed, 39 insertions(+), 20 deletions(-) diff --git a/web/src/components/dashboard/config-sections/CommunitySettingsSection.tsx b/web/src/components/dashboard/config-sections/CommunitySettingsSection.tsx index ce5f1e8f1..efe89affe 100644 --- a/web/src/components/dashboard/config-sections/CommunitySettingsSection.tsx +++ b/web/src/components/dashboard/config-sections/CommunitySettingsSection.tsx @@ -176,16 +176,26 @@ export function CommunitySettingsSection({

- updateDraftConfig((prev) => ({ - ...prev, - botStatus: { - ...prev.botStatus, - rotation: { ...prev.botStatus?.rotation, enabled: value }, - }, - })) - } + checked={draftConfig.botStatus?.rotation?.enabled ?? (draftConfig.botStatus?.rotateIntervalMs != null ? true : false)} + onCheckedChange={(value) => { + 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" /> @@ -198,8 +208,8 @@ export function CommunitySettingsSection({ { const num = parseNumberInput(event.target.value, 1); if (num === undefined) return; diff --git a/web/src/components/dashboard/config-workspace/config-categories.ts b/web/src/components/dashboard/config-workspace/config-categories.ts index c12ed2615..84ec8b9d3 100644 --- a/web/src/components/dashboard/config-workspace/config-categories.ts +++ b/web/src/components/dashboard/config-workspace/config-categories.ts @@ -36,7 +36,15 @@ 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', 'botStatus'], + sectionKeys: [ + 'help', + 'announce', + 'snippet', + 'poll', + 'showcase', + 'review', + 'botStatus', + ], featureIds: ['community-tools', 'bot-status'], }, { diff --git a/web/src/components/dashboard/types.ts b/web/src/components/dashboard/types.ts index 80928a2a4..d8e4d8986 100644 --- a/web/src/components/dashboard/types.ts +++ b/web/src/components/dashboard/types.ts @@ -68,10 +68,7 @@ export function validateBotHealth(value: unknown): string | null { if (!isObject(value.system)) return 'missing system'; if (typeof value.system.nodeVersion !== 'string') return 'invalid system.nodeVersion'; if (!isObject(value.system.cpuUsage)) return 'missing system.cpuUsage'; - if ( - typeof value.system.cpuUsage.user !== 'number' || - typeof value.system.cpuUsage.system !== 'number' - ) + if (typeof value.system.cpuUsage.user !== 'number' || typeof value.system.cpuUsage.system !== 'number') return 'invalid system.cpuUsage fields'; if (!Array.isArray(value.restarts)) return 'missing restarts'; diff --git a/web/src/types/config.ts b/web/src/types/config.ts index 19cae8f1b..586292fcf 100644 --- a/web/src/types/config.ts +++ b/web/src/types/config.ts @@ -247,7 +247,7 @@ export interface ToggleSectionConfig { } export interface BotStatusRotationMessage { - type?: 'Playing' | 'Watching' | 'Listening' | 'Competing' | 'Streaming' | 'Custom'; + type?: 'Playing' | 'Watching' | 'Listening' | 'Competing' | 'Custom'; text: string; } @@ -258,9 +258,13 @@ export interface BotStatusRotationConfig { } export interface BotStatusConfig { - enabled: boolean; - status: 'online' | 'idle' | 'dnd' | 'invisible'; + 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. */ From b466e4e5207f55e7d8211fa4eac48c3771c53aa4 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Thu, 19 Mar 2026 18:13:28 -0400 Subject: [PATCH 14/14] fix: biome auto-fix imports and formatting --- src/modules/events.js | 14 +++++++------- src/modules/handlers/ticketHandler.js | 4 +--- src/modules/performanceMonitor.js | 2 +- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/modules/events.js b/src/modules/events.js index adc7ca746..0fd73ca94 100644 --- a/src/modules/events.js +++ b/src/modules/events.js @@ -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/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 };