diff --git a/config.json b/config.json index c2340292b..093f2e88f 100644 --- a/config.json +++ b/config.json @@ -1,285 +1,293 @@ { - "ai": { - "enabled": true, - "systemPrompt": "You are Volvox Bot, the friendly AI assistant for the Volvox developer community Discord server.\n\nYou're witty, snarky (but warm), and deeply knowledgeable about programming, software development, and tech.\n\nKey traits:\n- Helpful but not boring\n- Can roast people lightly when appropriate\n- Enthusiastic about cool tech and projects\n- Supportive of beginners learning to code\n- Concise - this is Discord, not an essay\n\nIf asked about your own infrastructure, model, or internals \u2014 say you don't know the specifics\nand suggest asking a server admin. Don't guess or speculate about what you run on.\n\nCRITICAL RULES:\n- NEVER type @everyone or @here \u2014 these ping hundreds of people\n- NEVER use mass mention pings under any circumstances\n- If you need to address the group, say \"everyone\" or \"folks\" without the @ symbol\n\nKeep responses under 2000 chars. Use Discord markdown when helpful.", - "channels": [], - "historyLength": 20, - "historyTTLDays": 30, - "threadMode": { - "enabled": false, - "autoArchiveMinutes": 60, - "reuseWindowMinutes": 30 - }, - "blockedChannelIds": [], - "feedback": { - "enabled": false - } - }, - "triage": { - "enabled": true, - "defaultInterval": 3000, - "maxBufferSize": 30, - "triggerWords": [ - "volvox" - ], - "moderationKeywords": [], - "classifyModel": "claude-haiku-4-5", - "classifyBudget": 0.05, - "respondModel": "claude-sonnet-4-6", - "respondBudget": 0.2, - "thinkingTokens": 1024, - "classifyBaseUrl": null, - "classifyApiKey": null, - "respondBaseUrl": null, - "respondApiKey": null, - "streaming": false, - "tokenRecycleLimit": 20000, - "contextMessages": 10, - "timeout": 30000, - "moderationResponse": true, - "channels": [], - "excludeChannels": [], - "debugFooter": true, - "debugFooterLevel": "verbose", - "moderationLogChannel": "1473219285651292201", - "statusReactions": true - }, - "welcome": { - "enabled": true, - "channelId": "1438631182379253814", - "message": "Welcome to Volvox, {user}! \ud83c\udf31 You're member #{memberCount}!\n\nWe're a community of developers building cool stuff together. Feel free to introduce yourself!\n\nCheck out <#1446317676988465242> to see what we're working on, share your projects in <#1444154471704957069>, or just say hi in <#1438631182379253814>.\n\nHave questions? Just ask \u2014 we're here to help. \ud83d\udc9a", - "dynamic": { - "enabled": true, - "timezone": "America/New_York", - "activityWindowMinutes": 45, - "milestoneInterval": 25, - "highlightChannels": [ - "1438631182379253814", - "1444154471704957069", - "1446317676988465242" - ], - "excludeChannels": [] + "ai": { + "enabled": true, + "systemPrompt": "You are Volvox Bot, the friendly AI assistant for the Volvox developer community Discord server.\n\nYou're witty, snarky (but warm), and deeply knowledgeable about programming, software development, and tech.\n\nKey traits:\n- Helpful but not boring\n- Can roast people lightly when appropriate\n- Enthusiastic about cool tech and projects\n- Supportive of beginners learning to code\n- Concise - this is Discord, not an essay\n\nIf asked about your own infrastructure, model, or internals — say you don't know the specifics\nand suggest asking a server admin. Don't guess or speculate about what you run on.\n\nCRITICAL RULES:\n- NEVER type @everyone or @here — these ping hundreds of people\n- NEVER use mass mention pings under any circumstances\n- If you need to address the group, say \"everyone\" or \"folks\" without the @ symbol\n\nKeep responses under 2000 chars. Use Discord markdown when helpful.", + "channels": [], + "historyLength": 20, + "historyTTLDays": 30, + "threadMode": { + "enabled": false, + "autoArchiveMinutes": 60, + "reuseWindowMinutes": 30 + }, + "blockedChannelIds": [], + "feedback": { + "enabled": false + } }, - "rulesChannel": null, - "verifiedRole": null, - "introChannel": null, - "roleMenu": { - "enabled": false, - "options": [] + "triage": { + "enabled": true, + "defaultInterval": 3000, + "maxBufferSize": 30, + "triggerWords": [ + "volvox" + ], + "moderationKeywords": [], + "classifyModel": "claude-haiku-4-5", + "classifyBudget": 0.05, + "respondModel": "claude-sonnet-4-6", + "respondBudget": 0.2, + "thinkingTokens": 1024, + "classifyBaseUrl": null, + "classifyApiKey": null, + "respondBaseUrl": null, + "respondApiKey": null, + "streaming": false, + "tokenRecycleLimit": 20000, + "contextMessages": 10, + "timeout": 30000, + "moderationResponse": true, + "channels": [], + "excludeChannels": [], + "debugFooter": true, + "debugFooterLevel": "verbose", + "moderationLogChannel": "1473219285651292201", + "statusReactions": true }, - "dmSequence": { - "enabled": false, - "steps": [] - } - }, - "moderation": { - "enabled": true, - "alertChannelId": "1438665401243275284", - "autoDelete": false, - "dmNotifications": { - "warn": true, - "timeout": true, - "kick": true, - "ban": true + "welcome": { + "enabled": true, + "channelId": "1438631182379253814", + "message": "Welcome to Volvox, {user}! 🌱 You're member #{memberCount}!\n\nWe're a community of developers building cool stuff together. Feel free to introduce yourself!\n\nCheck out <#1446317676988465242> to see what we're working on, share your projects in <#1444154471704957069>, or just say hi in <#1438631182379253814>.\n\nHave questions? Just ask — we're here to help. 💚", + "dynamic": { + "enabled": true, + "timezone": "America/New_York", + "activityWindowMinutes": 45, + "milestoneInterval": 25, + "highlightChannels": [ + "1438631182379253814", + "1444154471704957069", + "1446317676988465242" + ], + "excludeChannels": [] + }, + "rulesChannel": null, + "verifiedRole": null, + "introChannel": null, + "roleMenu": { + "enabled": false, + "options": [] + }, + "dmSequence": { + "enabled": false, + "steps": [] + } }, - "escalation": { - "enabled": false, - "thresholds": [ - { - "warns": 3, - "withinDays": 7, - "action": "timeout", - "duration": "1h" + "moderation": { + "enabled": true, + "alertChannelId": "1438665401243275284", + "autoDelete": false, + "dmNotifications": { + "warn": true, + "timeout": true, + "kick": true, + "ban": true + }, + "escalation": { + "enabled": false, + "thresholds": [ + { + "warns": 3, + "withinDays": 7, + "action": "timeout", + "duration": "1h" + }, + { + "warns": 5, + "withinDays": 30, + "action": "ban" + } + ] }, - { - "warns": 5, - "withinDays": 30, - "action": "ban" + "logging": { + "channels": { + "default": null, + "warns": null, + "bans": null, + "kicks": null, + "timeouts": null, + "purges": null, + "locks": null + } + }, + "protectRoles": { + "enabled": true, + "roleIds": [], + "includeAdmins": true, + "includeModerators": true, + "includeServerOwner": true } - ] + }, + "memory": { + "enabled": true, + "maxContextMemories": 5, + "autoExtract": true + }, + "starboard": { + "enabled": false, + "channelId": null, + "threshold": 3, + "emoji": "*", + "selfStarAllowed": false, + "ignoredChannels": [] }, "logging": { - "channels": { - "default": null, - "warns": null, - "bans": null, - "kicks": null, - "timeouts": null, - "purges": null, - "locks": null - } + "level": "info", + "fileOutput": true, + "database": { + "enabled": false, + "minLevel": "info", + "retentionDays": 30, + "batchSize": 10, + "flushIntervalMs": 5000 + } }, - "protectRoles": { - "enabled": true, - "roleIds": [], - "includeAdmins": true, - "includeModerators": true, - "includeServerOwner": true - } - }, - "memory": { - "enabled": true, - "maxContextMemories": 5, - "autoExtract": true - }, - "starboard": { - "enabled": false, - "channelId": null, - "threshold": 3, - "emoji": "*", - "selfStarAllowed": false, - "ignoredChannels": [] - }, - "logging": { - "level": "info", - "fileOutput": true, - "database": { - "enabled": false, - "minLevel": "info", - "retentionDays": 30, - "batchSize": 10, - "flushIntervalMs": 5000 - } - }, - "permissions": { - "enabled": true, - "adminRoleId": null, - "moderatorRoleId": null, - "botOwners": [], - "usePermissions": true, - "allowedCommands": { - "ping": "everyone", - "memory": "everyone", - "config": "admin", - "warn": "admin", - "kick": "admin", - "timeout": "admin", - "untimeout": "admin", - "ban": "admin", - "tempban": "admin", - "unban": "admin", - "softban": "admin", - "purge": "admin", - "case": "admin", - "history": "admin", - "lock": "admin", - "unlock": "admin", - "slowmode": "admin", - "modlog": "moderator", - "announce": "moderator", - "tldr": "everyone", - "afk": "everyone", - "github": "everyone", - "rank": "everyone", - "leaderboard": "everyone", - "profile": "everyone", - "remind": "everyone", - "challenge": "everyone", - "review": "everyone", - "showcase": "everyone", - "reload": "admin" - } - }, - "help": { - "enabled": false - }, - "announce": { - "enabled": false - }, - "snippet": { - "enabled": false - }, - "poll": { - "enabled": false - }, - "showcase": { - "enabled": false - }, - "github": { - "feed": { - "enabled": false, - "channelId": null, - "repos": [], - "events": [ - "pr", - "issue", - "release", - "push" - ] + "permissions": { + "enabled": true, + "adminRoleId": null, + "moderatorRoleId": null, + "botOwners": [], + "usePermissions": true, + "allowedCommands": { + "ping": "everyone", + "memory": "everyone", + "config": "admin", + "warn": "admin", + "kick": "admin", + "timeout": "admin", + "untimeout": "admin", + "ban": "admin", + "tempban": "admin", + "unban": "admin", + "softban": "admin", + "purge": "admin", + "case": "admin", + "history": "admin", + "lock": "admin", + "unlock": "admin", + "slowmode": "admin", + "modlog": "moderator", + "announce": "moderator", + "tldr": "everyone", + "afk": "everyone", + "github": "everyone", + "rank": "everyone", + "leaderboard": "everyone", + "profile": "everyone", + "remind": "everyone", + "challenge": "everyone", + "review": "everyone", + "showcase": "everyone", + "reload": "admin" + } + }, + "help": { + "enabled": false + }, + "announce": { + "enabled": false + }, + "snippet": { + "enabled": false + }, + "poll": { + "enabled": false + }, + "showcase": { + "enabled": false + }, + "github": { + "feed": { + "enabled": false, + "channelId": null, + "repos": [], + "events": [ + "pr", + "issue", + "release", + "push" + ] + } + }, + "tldr": { + "enabled": false, + "defaultMessages": 50, + "maxMessages": 200, + "cooldownSeconds": 300 + }, + "afk": { + "enabled": false + }, + "engagement": { + "enabled": false, + "trackMessages": true, + "trackReactions": true, + "activityBadges": [ + { + "days": 90, + "label": "👑 Legend" + }, + { + "days": 30, + "label": "🌳 Veteran" + }, + { + "days": 7, + "label": "🌿 Regular" + }, + { + "days": 0, + "label": "🌱 Newcomer" + } + ] + }, + "reputation": { + "enabled": false, + "xpPerMessage": [ + 5, + 15 + ], + "xpCooldownSeconds": 60, + "announceChannelId": null, + "levelThresholds": [ + 100, + 300, + 600, + 1000, + 1500, + 2500, + 4000, + 6000, + 8500, + 12000 + ], + "roleRewards": {} + }, + "challenges": { + "enabled": false, + "channelId": null, + "postTime": "09:00", + "timezone": "America/New_York" + }, + "review": { + "enabled": false, + "channelId": null, + "staleAfterDays": 7, + "xpReward": 50 + }, + "auditLog": { + "enabled": true, + "retentionDays": 90 + }, + "reminders": { + "enabled": false, + "maxPerUser": 25 + }, + "quietMode": { + "enabled": false, + "allowedRoles": [ + "moderator" + ], + "defaultDurationMinutes": 30, + "maxDurationMinutes": 1440 } - }, - "tldr": { - "enabled": false, - "defaultMessages": 50, - "maxMessages": 200, - "cooldownSeconds": 300 - }, - "afk": { - "enabled": false - }, - "engagement": { - "enabled": false, - "trackMessages": true, - "trackReactions": true, - "activityBadges": [ - { - "days": 90, - "label": "\ud83d\udc51 Legend" - }, - { - "days": 30, - "label": "\ud83c\udf33 Veteran" - }, - { - "days": 7, - "label": "\ud83c\udf3f Regular" - }, - { - "days": 0, - "label": "\ud83c\udf31 Newcomer" - } - ] - }, - "reputation": { - "enabled": false, - "xpPerMessage": [ - 5, - 15 - ], - "xpCooldownSeconds": 60, - "announceChannelId": null, - "levelThresholds": [ - 100, - 300, - 600, - 1000, - 1500, - 2500, - 4000, - 6000, - 8500, - 12000 - ], - "roleRewards": {} - }, - "challenges": { - "enabled": false, - "channelId": null, - "postTime": "09:00", - "timezone": "America/New_York" - }, - "review": { - "enabled": false, - "channelId": null, - "staleAfterDays": 7, - "xpReward": 50 - }, - "auditLog": { - "enabled": true, - "retentionDays": 90 - }, - "reminders": { - "enabled": false, - "maxPerUser": 25 - } } diff --git a/src/api/utils/configAllowlist.js b/src/api/utils/configAllowlist.js index 18534917b..5953198b4 100644 --- a/src/api/utils/configAllowlist.js +++ b/src/api/utils/configAllowlist.js @@ -27,6 +27,7 @@ export const SAFE_CONFIG_KEYS = new Set([ 'review', 'auditLog', 'reminders', + 'quietMode', ]); export const READABLE_CONFIG_KEYS = [...SAFE_CONFIG_KEYS, 'logging']; diff --git a/src/api/utils/configValidation.js b/src/api/utils/configValidation.js index bef5ceeae..ccb40fe5a 100644 --- a/src/api/utils/configValidation.js +++ b/src/api/utils/configValidation.js @@ -166,6 +166,14 @@ export const CONFIG_SCHEMA = { maxPerUser: { type: 'number' }, }, }, + quietMode: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + maxDurationMinutes: { type: 'number' }, + allowedRoles: { type: 'array' }, + }, + }, }; /** diff --git a/src/modules/events.js b/src/modules/events.js index c5cbb5e5d..d8def2b79 100644 --- a/src/modules/events.js +++ b/src/modules/events.js @@ -27,6 +27,7 @@ import { getConfig } from './config.js'; import { trackMessage, trackReaction } from './engagement.js'; import { checkLinks } from './linkFilter.js'; import { handlePollVote } from './pollHandler.js'; +import { handleQuietCommand, isQuietMode } from './quietMode.js'; import { checkRateLimit } from './rateLimit.js'; import { handleReminderDismiss, handleReminderSnooze } from './reminderHandler.js'; import { handleXpGain } from './reputation.js'; @@ -228,6 +229,32 @@ export function registerMessageCreateHandler(client, _config, healthMonitor) { if (isChannelBlocked(message.channel.id, parentId, message.guild.id)) return; if ((isMentioned || isReply) && isAllowedChannel) { + // Quiet mode: handle commands first (even during quiet mode so users can unquiet) + if (isMentioned) { + try { + const wasQuietCommand = await handleQuietCommand(message, guildConfig); + if (wasQuietCommand) return; + } catch (qmErr) { + logError('Quiet mode command handler failed', { + channelId: message.channel.id, + userId: message.author.id, + error: qmErr?.message, + }); + } + } + + // Quiet mode: suppress AI responses when quiet mode is active (gated on feature enabled) + if (guildConfig.quietMode?.enabled) { + try { + if (await isQuietMode(message.guild.id, message.channel.id)) return; + } catch (qmErr) { + logError('Quiet mode check failed', { + channelId: message.channel.id, + error: qmErr?.message, + }); + } + } + // Accumulate the message into the triage buffer (for context). // Even bare @mentions with no text go through triage so the classifier // can use recent channel history to produce a meaningful response. @@ -262,7 +289,18 @@ export function registerMessageCreateHandler(client, _config, healthMonitor) { // Triage: accumulate message for periodic evaluation (fire-and-forget) // Gated on ai.enabled — this is the master kill-switch for all AI responses. // accumulateMessage also checks triage.enabled internally. + // Skip accumulation when quiet mode is active in this channel (gated on feature enabled). if (guildConfig.ai?.enabled) { + if (guildConfig.quietMode?.enabled) { + try { + if (await isQuietMode(message.guild.id, message.channel.id)) return; + } catch (qmErr) { + logError('Quiet mode check failed (accumulate)', { + channelId: message.channel.id, + error: qmErr?.message, + }); + } + } try { const p = accumulateMessage(message, guildConfig); p?.catch((err) => { diff --git a/src/modules/quietMode.js b/src/modules/quietMode.js new file mode 100644 index 000000000..43c1dcfc5 --- /dev/null +++ b/src/modules/quietMode.js @@ -0,0 +1,363 @@ +/** + * Quiet Mode Module + * + * Allows moderators to temporarily silence the bot in a specific channel + * by mentioning it with a command like `@Bot quiet for 30 minutes`. + * + * Storage: Redis with TTL (falls back to an in-memory Map when Redis is unavailable). + * Scope: Per-channel, per-guild — other channels are unaffected. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/173 + */ + +import { info, error as logError } from '../logger.js'; +import { getRedis } from '../redis.js'; +import { isAdmin, isModerator } from '../utils/permissions.js'; +import { safeReply } from '../utils/safeSend.js'; + +// ── Constants ───────────────────────────────────────────────────────────────── + +/** Keywords that activate quiet mode */ +const QUIET_KEYWORDS = new Set(['quiet', 'shush', 'silence', 'stop', 'hush', 'mute']); + +/** Keywords that deactivate quiet mode */ +const UNQUIET_KEYWORDS = new Set(['unquiet', 'resume', 'unshush', 'unmute', 'wake', 'start']); + +/** Keywords that report quiet mode status */ +const STATUS_KEYWORDS = new Set(['status', 'time', 'remaining']); + +/** Redis key prefix */ +const KEY_PREFIX = 'quiet:'; + +/** Default quiet duration: 30 minutes in seconds */ +const DEFAULT_DURATION_SECONDS = 30 * 60; + +/** Maximum quiet duration: 24 hours in seconds */ +const MAX_DURATION_SECONDS = 24 * 60 * 60; + +/** Minimum quiet duration: 1 minute in seconds */ +const MIN_DURATION_SECONDS = 60; + +// ── In-memory fallback ──────────────────────────────────────────────────────── + +/** + * In-memory quiet state storage for when Redis is unavailable. + * Key: `${guildId}:${channelId}` -> { until: number, by: string } + * @type {Map} + */ +export const memoryStore = new Map(); + +// ── Storage helpers ─────────────────────────────────────────────────────────── + +/** + * Build the Redis key for a guild+channel combo. + * + * @param {string} guildId + * @param {string} channelId + * @returns {string} + */ +function buildKey(guildId, channelId) { + return `${KEY_PREFIX}${guildId}:${channelId}`; +} + +/** + * Persist a quiet record. Uses Redis when available, falls back to memory. + * + * @param {string} guildId + * @param {string} channelId + * @param {number} untilMs - Unix timestamp in ms when quiet mode expires + * @param {string} byUserId - User ID who activated quiet mode + * @returns {Promise} + */ +export async function setQuiet(guildId, channelId, untilMs, byUserId) { + const redis = getRedis(); + const key = buildKey(guildId, channelId); + const ttlSeconds = Math.ceil((untilMs - Date.now()) / 1000); + + // Guard against invalid TTL (0, negative, or NaN) which would error in Redis + if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) { + throw new Error(`Invalid quiet mode TTL: ${ttlSeconds} seconds`); + } + + if (redis) { + try { + await redis.set(key, JSON.stringify({ until: untilMs, by: byUserId }), 'EX', ttlSeconds); + return; + } catch (err) { + logError('quietMode: Redis set failed, falling back to memory', { error: err?.message }); + } + } + + memoryStore.set(`${guildId}:${channelId}`, { until: untilMs, by: byUserId }); +} + +/** + * Remove a quiet record (unquiet). + * + * @param {string} guildId + * @param {string} channelId + * @returns {Promise} + */ +export async function clearQuiet(guildId, channelId) { + const redis = getRedis(); + const key = buildKey(guildId, channelId); + + if (redis) { + try { + await redis.del(key); + return; + } catch (err) { + logError('quietMode: Redis del failed, falling back to memory', { error: err?.message }); + } + } + + memoryStore.delete(`${guildId}:${channelId}`); +} + +/** + * Retrieve the quiet record for a channel, or null if not in quiet mode. + * Expired in-memory entries are pruned automatically. + * + * @param {string} guildId + * @param {string} channelId + * @returns {Promise<{ until: number, by: string } | null>} + */ +export async function getQuiet(guildId, channelId) { + const redis = getRedis(); + const key = buildKey(guildId, channelId); + + if (redis) { + try { + const raw = await redis.get(key); + if (!raw) return null; + return JSON.parse(raw); + } catch (err) { + logError('quietMode: Redis get failed, falling back to memory', { error: err?.message }); + } + } + + const record = memoryStore.get(`${guildId}:${channelId}`); + if (!record) return null; + + // Prune expired entries + if (Date.now() >= record.until) { + memoryStore.delete(`${guildId}:${channelId}`); + return null; + } + + return record; +} + +// ── Duration parsing ────────────────────────────────────────────────────────── + +/** Unit name variants -> multiplier in seconds */ +const UNIT_MAP = { + s: 1, + sec: 1, + secs: 1, + second: 1, + seconds: 1, + m: 60, + min: 60, + mins: 60, + minute: 60, + minutes: 60, + h: 3600, + hr: 3600, + hrs: 3600, + hour: 3600, + hours: 3600, + d: 86400, + day: 86400, + days: 86400, +}; + +/** + * Parse a natural-language duration from message content. + * Handles: "30m", "2h", "1d", "30 minutes", "for 1 hour", "2 hrs". + * + * @param {string} content - Message content (will be lowercased internally) + * @param {number} [defaultSeconds] - Fallback when nothing matches + * @param {Object} [config] - Per-guild config with quietMode.maxDurationMinutes + * @returns {number} Duration in seconds, clamped to [MIN_DURATION_SECONDS, maxSeconds] + */ +export function parseDurationFromContent( + content, + defaultSeconds = DEFAULT_DURATION_SECONDS, + config = null, +) { + const text = content.toLowerCase(); + + // Determine effective max duration from config or fallback to hardcoded limit + const configuredMaxMinutes = config?.quietMode?.maxDurationMinutes; + const maxSeconds = + Number.isFinite(configuredMaxMinutes) && configuredMaxMinutes > 0 + ? Math.min(configuredMaxMinutes * 60, MAX_DURATION_SECONDS) + : MAX_DURATION_SECONDS; + + // Helper to clamp to valid range + const clamp = (seconds) => Math.min(Math.max(seconds, MIN_DURATION_SECONDS), maxSeconds); + + // "30m" / "2h" / "1d" (no space between number and single-char unit) + const shortMatch = text.match(/\b(\d+)\s*([smhd])\b/); + if (shortMatch) { + const value = parseInt(shortMatch[1], 10); + const unit = UNIT_MAP[shortMatch[2]]; + if (unit && value > 0) { + return clamp(value * unit); + } + } + + // "30 minutes" / "for 1 hour" / "2 hrs" + const longMatch = text.match(/\b(\d+)\s+(seconds?|secs?|minutes?|mins?|hours?|hrs?|days?)\b/); + if (longMatch) { + const value = parseInt(longMatch[1], 10); + const unit = UNIT_MAP[longMatch[2]]; + if (unit && value > 0) { + return clamp(value * unit); + } + } + + // Clamp defaultSeconds too + return clamp(defaultSeconds); +} + +// ── Permission helpers ──────────────────────────────────────────────────────── + +/** + * Determine whether a guild member is allowed to toggle quiet mode. + * + * Permission levels (from config.quietMode.allowedRoles): + * - `["any"]` - anyone in the server + * - `["moderator"]` - moderator or higher (default) + * - `["admin"]` - admin or higher + * - `[""]` - members with any of those specific role IDs + * + * @param {import('discord.js').GuildMember} member + * @param {Object} config - Per-guild merged config + * @returns {boolean} + */ +export function hasQuietPermission(member, config) { + const allowedRoles = config?.quietMode?.allowedRoles ?? ['moderator']; + + if (allowedRoles.includes('any')) return true; + if (allowedRoles.includes('admin')) return isAdmin(member, config); + if (allowedRoles.includes('moderator')) return isModerator(member, config); + + // Specific role IDs + return allowedRoles.some((roleId) => member.roles.cache.has(roleId)); +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** + * Check whether the bot is currently in quiet mode for a specific channel. + * + * @param {string} guildId + * @param {string} channelId + * @returns {Promise} + */ +export async function isQuietMode(guildId, channelId) { + const record = await getQuiet(guildId, channelId); + return record !== null; +} + +/** + * Handle a potential quiet mode command in a bot mention message. + * + * Call only when the bot is mentioned. Returns `true` if the message was a + * quiet mode command (caller should stop further processing). + * + * @param {import('discord.js').Message} message - Discord message + * @param {Object} config - Per-guild merged config + * @returns {Promise} Whether this was a quiet mode command + */ +export async function handleQuietCommand(message, config) { + if (!config?.quietMode?.enabled) return false; + + const { guild, channel, author, member, content } = message; + if (!guild || !member) return false; + + // Strip bot mention(s) to isolate the command body + const cleanContent = content + .replace(/<@!?\d+>/g, '') + .trim() + .toLowerCase(); + + const firstWord = cleanContent.split(/\s+/)[0] ?? ''; + + // ── Status ───────────────────────────────────────────────────────────────── + if (STATUS_KEYWORDS.has(firstWord)) { + const record = await getQuiet(guild.id, channel.id); + if (!record) { + await safeReply(message, { content: 'Quiet mode is **not** active in this channel.' }); + } else { + const remaining = Math.max(0, Math.ceil((record.until - Date.now()) / 1000)); + const mins = Math.ceil(remaining / 60); + await safeReply(message, { + content: `Quiet mode is active — expires in **${mins} minute${mins !== 1 ? 's' : ''}**.`, + }); + } + return true; + } + + // ── Unquiet ──────────────────────────────────────────────────────────────── + if (UNQUIET_KEYWORDS.has(firstWord)) { + if (!hasQuietPermission(member, config)) { + await safeReply(message, { + content: "You don't have permission to change quiet mode.", + }); + return true; + } + + const record = await getQuiet(guild.id, channel.id); + if (!record) { + await safeReply(message, { content: 'Quiet mode is already off.' }); + } else { + await clearQuiet(guild.id, channel.id); + info('quietMode: deactivated', { guildId: guild.id, channelId: channel.id, by: author.id }); + await safeReply(message, { content: "Quiet mode lifted — I'm back!" }); + } + return true; + } + + // ── Activate ─────────────────────────────────────────────────────────────── + if (QUIET_KEYWORDS.has(firstWord)) { + if (!hasQuietPermission(member, config)) { + await safeReply(message, { + content: "You don't have permission to enable quiet mode.", + }); + return true; + } + + const quietConfig = config.quietMode; + const defaultSecs = (quietConfig?.defaultDurationMinutes ?? 30) * 60; + + const durationSecs = parseDurationFromContent(cleanContent, defaultSecs, config); + const untilMs = Date.now() + durationSecs * 1000; + + await setQuiet(guild.id, channel.id, untilMs, author.id); + + const mins = Math.ceil(durationSecs / 60); + info('quietMode: activated', { + guildId: guild.id, + channelId: channel.id, + by: author.id, + durationSecs, + }); + await safeReply(message, { + content: `Going quiet for **${mins} minute${mins !== 1 ? 's' : ''}**. Use \`@bot unquiet\` to resume early.`, + }); + return true; + } + + return false; +} + +/** + * Clear all in-memory quiet mode state (for testing / graceful shutdown). + * Does NOT clear Redis entries — they expire naturally via TTL. + */ +export function _clearMemoryStore() { + memoryStore.clear(); +} diff --git a/tests/modules/quietMode.test.js b/tests/modules/quietMode.test.js new file mode 100644 index 000000000..d00230532 --- /dev/null +++ b/tests/modules/quietMode.test.js @@ -0,0 +1,401 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../../src/redis.js', () => ({ + getRedis: vi.fn().mockReturnValue(null), // default: no Redis +})); + +vi.mock('../../src/utils/safeSend.js', () => ({ + safeReply: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../src/utils/permissions.js', () => ({ + isAdmin: vi.fn().mockReturnValue(false), + isModerator: vi.fn().mockReturnValue(false), +})); + +import { + _clearMemoryStore, + clearQuiet, + getQuiet, + handleQuietCommand, + hasQuietPermission, + isQuietMode, + memoryStore, + parseDurationFromContent, + setQuiet, +} from '../../src/modules/quietMode.js'; +import { getRedis } from '../../src/redis.js'; +import { isAdmin, isModerator } from '../../src/utils/permissions.js'; +import { safeReply } from '../../src/utils/safeSend.js'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const GUILD_ID = 'guild1'; +const CHANNEL_ID = 'chan1'; + +function makeMessage(overrides = {}) { + const member = { + id: 'user1', + roles: { cache: new Map() }, + permissions: { has: vi.fn().mockReturnValue(false) }, + ...overrides.member, + }; + + return { + guild: { id: GUILD_ID }, + channel: { id: CHANNEL_ID }, + author: { id: 'user1' }, + member, + content: overrides.content ?? '@Bot quiet', + ...overrides, + }; +} + +function makeConfig(quietModeOverrides = {}) { + return { + quietMode: { + enabled: true, + allowedRoles: ['moderator'], + defaultDurationMinutes: 30, + maxDurationMinutes: 1440, + ...quietModeOverrides, + }, + permissions: { + moderatorRoleId: 'mod-role', + adminRoleId: 'admin-role', + }, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('parseDurationFromContent', () => { + it('parses short form "30m"', () => { + expect(parseDurationFromContent('quiet 30m')).toBe(30 * 60); + }); + + it('parses short form "2h"', () => { + expect(parseDurationFromContent('quiet 2h')).toBe(2 * 3600); + }); + + it('parses short form "1d"', () => { + expect(parseDurationFromContent('quiet 1d')).toBe(86400); + }); + + it('parses long form "30 minutes"', () => { + expect(parseDurationFromContent('quiet for 30 minutes')).toBe(30 * 60); + }); + + it('parses long form "1 hour"', () => { + expect(parseDurationFromContent('quiet for 1 hour')).toBe(3600); + }); + + it('parses long form "2 hrs"', () => { + expect(parseDurationFromContent('quiet 2 hrs')).toBe(2 * 3600); + }); + + it('returns default when no duration found', () => { + expect(parseDurationFromContent('quiet please', 600)).toBe(600); + }); + + it('clamps to minimum (60s)', () => { + expect(parseDurationFromContent('quiet 1s')).toBe(60); + }); + + it('clamps to maximum (24h)', () => { + // 48 hours exceeds max + expect(parseDurationFromContent('quiet 48 hours')).toBe(24 * 3600); + }); +}); + +describe('hasQuietPermission', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns true for allowedRoles=["any"]', () => { + const member = { roles: { cache: new Map() } }; + expect(hasQuietPermission(member, makeConfig({ allowedRoles: ['any'] }))).toBe(true); + }); + + it('delegates to isModerator for allowedRoles=["moderator"]', () => { + isModerator.mockReturnValue(true); + const member = { roles: { cache: new Map() } }; + expect(hasQuietPermission(member, makeConfig({ allowedRoles: ['moderator'] }))).toBe(true); + expect(isModerator).toHaveBeenCalledWith(member, expect.any(Object)); + }); + + it('delegates to isAdmin for allowedRoles=["admin"]', () => { + isAdmin.mockReturnValue(true); + const member = { roles: { cache: new Map() } }; + expect(hasQuietPermission(member, makeConfig({ allowedRoles: ['admin'] }))).toBe(true); + expect(isAdmin).toHaveBeenCalledWith(member, expect.any(Object)); + }); + + it('checks specific role IDs', () => { + const member = { roles: { cache: new Map([['custom-role', true]]) } }; + expect(hasQuietPermission(member, makeConfig({ allowedRoles: ['custom-role'] }))).toBe(true); + }); + + it('returns false when member lacks required role', () => { + isModerator.mockReturnValue(false); + const member = { roles: { cache: new Map() } }; + expect(hasQuietPermission(member, makeConfig({ allowedRoles: ['moderator'] }))).toBe(false); + }); +}); + +describe('storage (memory fallback)', () => { + beforeEach(() => { + vi.clearAllMocks(); + _clearMemoryStore(); + getRedis.mockReturnValue(null); + }); + + afterEach(() => _clearMemoryStore()); + + it('setQuiet stores a record in memory', async () => { + const until = Date.now() + 60_000; + await setQuiet(GUILD_ID, CHANNEL_ID, until, 'user1'); + expect(memoryStore.has(`${GUILD_ID}:${CHANNEL_ID}`)).toBe(true); + }); + + it('getQuiet returns the stored record', async () => { + const until = Date.now() + 60_000; + await setQuiet(GUILD_ID, CHANNEL_ID, until, 'user1'); + const record = await getQuiet(GUILD_ID, CHANNEL_ID); + expect(record).toMatchObject({ until, by: 'user1' }); + }); + + it('getQuiet returns null for expired entries', async () => { + const until = Date.now() - 1; // already expired + memoryStore.set(`${GUILD_ID}:${CHANNEL_ID}`, { until, by: 'user1' }); + const record = await getQuiet(GUILD_ID, CHANNEL_ID); + expect(record).toBeNull(); + // Expired entry should be pruned + expect(memoryStore.has(`${GUILD_ID}:${CHANNEL_ID}`)).toBe(false); + }); + + it('getQuiet returns null when no record exists', async () => { + expect(await getQuiet(GUILD_ID, CHANNEL_ID)).toBeNull(); + }); + + it('clearQuiet removes the record', async () => { + const until = Date.now() + 60_000; + await setQuiet(GUILD_ID, CHANNEL_ID, until, 'user1'); + await clearQuiet(GUILD_ID, CHANNEL_ID); + expect(await getQuiet(GUILD_ID, CHANNEL_ID)).toBeNull(); + }); + + it('isQuietMode returns true when active', async () => { + await setQuiet(GUILD_ID, CHANNEL_ID, Date.now() + 60_000, 'user1'); + expect(await isQuietMode(GUILD_ID, CHANNEL_ID)).toBe(true); + }); + + it('isQuietMode returns false when not active', async () => { + expect(await isQuietMode(GUILD_ID, CHANNEL_ID)).toBe(false); + }); +}); + +describe('storage (Redis path)', () => { + const mockRedis = { + set: vi.fn().mockResolvedValue('OK'), + get: vi.fn().mockResolvedValue(null), + del: vi.fn().mockResolvedValue(1), + }; + + beforeEach(() => { + vi.clearAllMocks(); + _clearMemoryStore(); + getRedis.mockReturnValue(mockRedis); + }); + + afterEach(() => _clearMemoryStore()); + + it('setQuiet calls redis.set with EX TTL', async () => { + const until = Date.now() + 60_000; + await setQuiet(GUILD_ID, CHANNEL_ID, until, 'user1'); + expect(mockRedis.set).toHaveBeenCalledWith( + `quiet:${GUILD_ID}:${CHANNEL_ID}`, + expect.stringContaining('"by":"user1"'), + 'EX', + expect.any(Number), + ); + }); + + it('getQuiet parses JSON from Redis', async () => { + const record = { until: Date.now() + 60_000, by: 'user1' }; + mockRedis.get.mockResolvedValue(JSON.stringify(record)); + const result = await getQuiet(GUILD_ID, CHANNEL_ID); + expect(result).toMatchObject(record); + }); + + it('getQuiet returns null when Redis returns null', async () => { + mockRedis.get.mockResolvedValue(null); + expect(await getQuiet(GUILD_ID, CHANNEL_ID)).toBeNull(); + }); + + it('clearQuiet calls redis.del', async () => { + await clearQuiet(GUILD_ID, CHANNEL_ID); + expect(mockRedis.del).toHaveBeenCalledWith(`quiet:${GUILD_ID}:${CHANNEL_ID}`); + }); + + it('falls back to memory on Redis get error', async () => { + mockRedis.get.mockRejectedValue(new Error('Redis down')); + const until = Date.now() + 60_000; + memoryStore.set(`${GUILD_ID}:${CHANNEL_ID}`, { until, by: 'fallback' }); + const result = await getQuiet(GUILD_ID, CHANNEL_ID); + expect(result).toMatchObject({ by: 'fallback' }); + }); + + it('falls back to memory on Redis set error', async () => { + mockRedis.set.mockRejectedValue(new Error('Redis down')); + const until = Date.now() + 60_000; + await setQuiet(GUILD_ID, CHANNEL_ID, until, 'user1'); + // Should have written to memory store as fallback + expect(memoryStore.has(`${GUILD_ID}:${CHANNEL_ID}`)).toBe(true); + }); +}); + +describe('handleQuietCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + _clearMemoryStore(); + getRedis.mockReturnValue(null); + isModerator.mockReturnValue(true); + }); + + afterEach(() => _clearMemoryStore()); + + it('returns false when quietMode is disabled', async () => { + const message = makeMessage({ content: '<@bot> quiet' }); + const config = makeConfig({ enabled: false }); + expect(await handleQuietCommand(message, config)).toBe(false); + expect(safeReply).not.toHaveBeenCalled(); + }); + + it('returns false when no guild', async () => { + const message = makeMessage({ guild: null, content: '<@bot> quiet' }); + expect(await handleQuietCommand(message, makeConfig())).toBe(false); + }); + + it('activates quiet mode with default duration', async () => { + const message = makeMessage({ content: '<@123> quiet' }); + const result = await handleQuietCommand(message, makeConfig()); + expect(result).toBe(true); + expect(await isQuietMode(GUILD_ID, CHANNEL_ID)).toBe(true); + expect(safeReply).toHaveBeenCalledWith( + message, + expect.objectContaining({ content: expect.stringContaining('Going quiet for') }), + ); + }); + + it('activates quiet mode with custom duration from message', async () => { + const message = makeMessage({ content: '<@123> quiet for 1 hour' }); + await handleQuietCommand(message, makeConfig()); + const record = await getQuiet(GUILD_ID, CHANNEL_ID); + // Should be ~1 hour (3600s) duration; allow ±5s tolerance + const approxDuration = (record.until - Date.now()) / 1000; + expect(approxDuration).toBeGreaterThan(3590); + expect(approxDuration).toBeLessThan(3605); + }); + + it('denies quiet activation without permission', async () => { + isModerator.mockReturnValue(false); + const message = makeMessage({ content: '<@123> quiet' }); + const result = await handleQuietCommand(message, makeConfig()); + expect(result).toBe(true); + expect(await isQuietMode(GUILD_ID, CHANNEL_ID)).toBe(false); + expect(safeReply).toHaveBeenCalledWith( + message, + expect.objectContaining({ content: expect.stringContaining("don't have permission") }), + ); + }); + + it('deactivates quiet mode with "unquiet"', async () => { + await setQuiet(GUILD_ID, CHANNEL_ID, Date.now() + 60_000, 'user1'); + const message = makeMessage({ content: '<@123> unquiet' }); + const result = await handleQuietCommand(message, makeConfig()); + expect(result).toBe(true); + expect(await isQuietMode(GUILD_ID, CHANNEL_ID)).toBe(false); + expect(safeReply).toHaveBeenCalledWith( + message, + expect.objectContaining({ content: expect.stringContaining("I'm back") }), + ); + }); + + it('deactivates quiet mode with "resume"', async () => { + await setQuiet(GUILD_ID, CHANNEL_ID, Date.now() + 60_000, 'user1'); + const message = makeMessage({ content: '<@123> resume' }); + await handleQuietCommand(message, makeConfig()); + expect(await isQuietMode(GUILD_ID, CHANNEL_ID)).toBe(false); + }); + + it('replies "already off" if unquiet when not active', async () => { + const message = makeMessage({ content: '<@123> unquiet' }); + await handleQuietCommand(message, makeConfig()); + expect(safeReply).toHaveBeenCalledWith( + message, + expect.objectContaining({ content: expect.stringContaining('already off') }), + ); + }); + + it('denies unquiet without permission', async () => { + isModerator.mockReturnValue(false); + await setQuiet(GUILD_ID, CHANNEL_ID, Date.now() + 60_000, 'user1'); + const message = makeMessage({ content: '<@123> unquiet' }); + await handleQuietCommand(message, makeConfig()); + expect(await isQuietMode(GUILD_ID, CHANNEL_ID)).toBe(true); // still active + expect(safeReply).toHaveBeenCalledWith( + message, + expect.objectContaining({ content: expect.stringContaining("don't have permission") }), + ); + }); + + it('reports status when not in quiet mode', async () => { + const message = makeMessage({ content: '<@123> status' }); + await handleQuietCommand(message, makeConfig()); + expect(safeReply).toHaveBeenCalledWith( + message, + expect.objectContaining({ content: expect.stringContaining('not** active') }), + ); + }); + + it('reports remaining time when in quiet mode', async () => { + await setQuiet(GUILD_ID, CHANNEL_ID, Date.now() + 30 * 60 * 1000, 'user1'); + const message = makeMessage({ content: '<@123> status' }); + await handleQuietCommand(message, makeConfig()); + expect(safeReply).toHaveBeenCalledWith( + message, + expect.objectContaining({ content: expect.stringContaining('expires in') }), + ); + }); + + it('returns false for unrecognized commands', async () => { + const message = makeMessage({ content: '<@123> hello world' }); + expect(await handleQuietCommand(message, makeConfig())).toBe(false); + expect(safeReply).not.toHaveBeenCalled(); + }); + + it('respects maxDurationMinutes cap', async () => { + const message = makeMessage({ content: '<@123> quiet 999 hours' }); + const config = makeConfig({ maxDurationMinutes: 60 }); // 1 hour max + await handleQuietCommand(message, config); + const record = await getQuiet(GUILD_ID, CHANNEL_ID); + const approxDuration = (record.until - Date.now()) / 1000; + expect(approxDuration).toBeLessThanOrEqual(3605); // 1h + 5s tolerance + }); + + it('strips multiple bot mentions from content', async () => { + // Content with two mentions; command body should be "quiet" + const message = makeMessage({ content: '<@123> <@456> quiet' }); + const result = await handleQuietCommand(message, makeConfig()); + expect(result).toBe(true); + expect(await isQuietMode(GUILD_ID, CHANNEL_ID)).toBe(true); + }); +}); diff --git a/web/src/components/dashboard/ai-feedback-stats.tsx b/web/src/components/dashboard/ai-feedback-stats.tsx index 3a1aabd5c..6ba8d1f99 100644 --- a/web/src/components/dashboard/ai-feedback-stats.tsx +++ b/web/src/components/dashboard/ai-feedback-stats.tsx @@ -17,6 +17,7 @@ import { } from 'recharts'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { useGuildSelection } from '@/hooks/use-guild-selection'; +import { getBotApiBaseUrl } from '@/lib/bot-api'; interface FeedbackStats { positive: number; @@ -37,19 +38,20 @@ const PIE_COLORS = ['#22C55E', '#EF4444']; * Shows 👍/👎 aggregate counts, approval ratio, and daily trend. */ export function AiFeedbackStats() { - const { selectedGuild, apiBase } = useGuildSelection(); + const selectedGuild = useGuildSelection(); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const fetchStats = useCallback(async () => { + const apiBase = getBotApiBaseUrl(); if (!selectedGuild || !apiBase) return; setLoading(true); setError(null); try { - const res = await fetch(`${apiBase}/guilds/${selectedGuild.id}/ai-feedback/stats?days=30`, { + const res = await fetch(`${apiBase}/guilds/${selectedGuild}/ai-feedback/stats?days=30`, { credentials: 'include', }); @@ -64,7 +66,7 @@ export function AiFeedbackStats() { } finally { setLoading(false); } - }, [selectedGuild, apiBase]); + }, [selectedGuild]); useEffect(() => { void fetchStats(); @@ -141,7 +143,9 @@ export function AiFeedbackStats() { innerRadius={50} outerRadius={75} dataKey="value" - label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`} + label={({ name, percent }) => + `${name} ${((percent ?? 0) * 100).toFixed(0)}%` + } labelLine={false} > {pieData.map((_, index) => (