diff --git a/src/api/routes/community.js b/src/api/routes/community.js index 0a769a60..68a22a33 100644 --- a/src/api/routes/community.js +++ b/src/api/routes/community.js @@ -12,12 +12,16 @@ import { getConfig } from '../../modules/config.js'; import { computeLevel } from '../../modules/reputation.js'; import { REPUTATION_DEFAULTS } from '../../modules/reputationDefaults.js'; import { cacheGetOrSet, TTL } from '../../utils/cache.js'; -import { rateLimit } from '../middleware/rateLimit.js'; +import { redisRateLimit } from '../middleware/redisRateLimit.js'; const router = Router(); /** Aggressive rate limiter for public endpoints: 30 req/min per IP */ -const communityRateLimit = rateLimit({ windowMs: 60 * 1000, max: 30 }); +const communityRateLimit = redisRateLimit({ + windowMs: 60 * 1000, + max: 30, + keyPrefix: 'rl:community', +}); router.use(communityRateLimit); /** diff --git a/src/api/server.js b/src/api/server.js index 5c502c39..7196e93a 100644 --- a/src/api/server.js +++ b/src/api/server.js @@ -7,7 +7,7 @@ import express from 'express'; import { error, info, warn } from '../logger.js'; import { PerformanceMonitor } from '../modules/performanceMonitor.js'; import apiRouter from './index.js'; -import { rateLimit } from './middleware/rateLimit.js'; +import { redisRateLimit } from './middleware/redisRateLimit.js'; import { stopAuthCleanup } from './routes/auth.js'; import { swaggerSpec } from './swagger.js'; import { stopGuildCacheCleanup } from './utils/discordApi.js'; @@ -17,7 +17,7 @@ import { setupLogStream, stopLogStream } from './ws/logStream.js'; /** @type {import('node:http').Server | null} */ let server = null; -/** @type {ReturnType | null} */ +/** @type {ReturnType | null} */ let rateLimiter = null; /** @@ -64,7 +64,7 @@ export function createApp(client, dbPool) { rateLimiter.destroy(); rateLimiter = null; } - rateLimiter = rateLimit(); + rateLimiter = redisRateLimit(); app.use(rateLimiter); // Raw OpenAPI spec (JSON) — public for Mintlify diff --git a/src/commands/leaderboard.js b/src/commands/leaderboard.js index 3d6b924b..c43a8850 100644 --- a/src/commands/leaderboard.js +++ b/src/commands/leaderboard.js @@ -9,6 +9,7 @@ import { EmbedBuilder, SlashCommandBuilder } from 'discord.js'; import { getPool } from '../db.js'; import { error as logError } from '../logger.js'; import { getConfig } from '../modules/config.js'; +import { getLeaderboardCached } from '../utils/reputationCache.js'; import { safeEditReply } from '../utils/safeSend.js'; export const data = new SlashCommandBuilder() @@ -30,14 +31,17 @@ export async function execute(interaction) { try { const pool = getPool(); - const { rows } = await pool.query( - `SELECT user_id, xp, level - FROM reputation - WHERE guild_id = $1 - ORDER BY xp DESC - LIMIT 10`, - [interaction.guildId], - ); + const rows = await getLeaderboardCached(interaction.guildId, async () => { + const result = await pool.query( + `SELECT user_id, xp, level + FROM reputation + WHERE guild_id = $1 + ORDER BY xp DESC + LIMIT 10`, + [interaction.guildId], + ); + return result.rows; + }); if (rows.length === 0) { await safeEditReply(interaction, { diff --git a/src/commands/rank.js b/src/commands/rank.js index 25bd2b01..b86157b1 100644 --- a/src/commands/rank.js +++ b/src/commands/rank.js @@ -11,6 +11,11 @@ import { error as logError } from '../logger.js'; import { getConfig } from '../modules/config.js'; import { buildProgressBar, computeLevel } from '../modules/reputation.js'; import { REPUTATION_DEFAULTS } from '../modules/reputationDefaults.js'; +import { + getRankCached, + getReputationCached, + setReputationCache, +} from '../utils/reputationCache.js'; import { safeEditReply } from '../utils/safeSend.js'; export const data = new SlashCommandBuilder() @@ -43,15 +48,23 @@ export async function execute(interaction) { const repCfg = { ...REPUTATION_DEFAULTS, ...cfg.reputation }; const thresholds = repCfg.levelThresholds; - // Fetch reputation row - const { rows } = await pool.query( - 'SELECT xp, level, messages_count FROM reputation WHERE guild_id = $1 AND user_id = $2', - [interaction.guildId, target.id], - ); + // Fetch reputation row (cached) + const cachedRep = await getReputationCached(interaction.guildId, target.id); + let repRow = cachedRep; + if (!repRow) { + const { rows } = await pool.query( + 'SELECT xp, level, messages_count FROM reputation WHERE guild_id = $1 AND user_id = $2', + [interaction.guildId, target.id], + ); + repRow = rows[0] ?? null; + if (repRow) { + await setReputationCache(interaction.guildId, target.id, repRow); + } + } - const xp = rows[0]?.xp ?? 0; + const xp = repRow?.xp ?? 0; const level = computeLevel(xp, thresholds); - const messagesCount = rows[0]?.messages_count ?? 0; + const messagesCount = repRow?.messages_count ?? 0; // XP within current level and needed for next const currentThreshold = level > 0 ? thresholds[level - 1] : 0; @@ -62,14 +75,16 @@ export async function execute(interaction) { const progressBar = nextThreshold !== null ? buildProgressBar(xpInLevel, xpNeeded) : `${'▓'.repeat(10)} MAX`; - // Rank position in guild - const rankRow = await pool.query( - `SELECT COUNT(*) + 1 AS rank - FROM reputation - WHERE guild_id = $1 AND xp > $2`, - [interaction.guildId, xp], - ); - const rank = Number(rankRow.rows[0]?.rank ?? 1); + // Rank position in guild (cached) + const rank = await getRankCached(interaction.guildId, target.id, async () => { + const rankRow = await pool.query( + `SELECT COUNT(*) + 1 AS rank + FROM reputation + WHERE guild_id = $1 AND xp > $2`, + [interaction.guildId, xp], + ); + return { rank: Number(rankRow.rows[0]?.rank ?? 1) }; + }).then((r) => r?.rank ?? 1); const levelLabel = `Level ${level}`; const xpLabel = nextThreshold !== null ? `${xp} / ${nextThreshold} XP` : `${xp} XP (Max Level)`; diff --git a/src/commands/review.js b/src/commands/review.js index 6ac18a42..1b414029 100644 --- a/src/commands/review.js +++ b/src/commands/review.js @@ -15,6 +15,7 @@ import { STATUS_LABELS, updateReviewMessage, } from '../modules/reviewHandler.js'; +import { fetchChannelCached } from '../utils/discordCache.js'; import { safeEditReply } from '../utils/safeSend.js'; export const data = new SlashCommandBuilder() @@ -159,10 +160,10 @@ async function handleRequest(interaction, pool, guildConfig) { let targetChannel = interaction.channel; if (reviewChannelId && reviewChannelId !== interaction.channelId) { - try { - const fetched = await interaction.client.channels.fetch(reviewChannelId); - if (fetched) targetChannel = fetched; - } catch { + const fetched = await fetchChannelCached(interaction.client, reviewChannelId); + if (fetched) { + targetChannel = fetched; + } else { warn('Review channel not found, using current channel', { reviewChannelId, guildId: interaction.guildId, diff --git a/src/commands/welcome.js b/src/commands/welcome.js index 4bbd0b13..5129e497 100644 --- a/src/commands/welcome.js +++ b/src/commands/welcome.js @@ -6,6 +6,7 @@ import { buildRulesAgreementMessage, normalizeWelcomeOnboardingConfig, } from '../modules/welcomeOnboarding.js'; +import { fetchChannelCached } from '../utils/discordCache.js'; import { isModerator } from '../utils/permissions.js'; import { safeEditReply, safeSend } from '../utils/safeSend.js'; @@ -36,9 +37,7 @@ export async function execute(interaction) { const resultLines = []; if (onboarding.rulesChannel) { - const rulesChannel = - interaction.guild.channels.cache.get(onboarding.rulesChannel) || - (await interaction.guild.channels.fetch(onboarding.rulesChannel).catch(() => null)); + const rulesChannel = await fetchChannelCached(interaction.client, onboarding.rulesChannel); if (rulesChannel?.isTextBased?.()) { const rulesMsg = buildRulesAgreementMessage(); @@ -53,9 +52,10 @@ export async function execute(interaction) { const roleMenuMsg = buildRoleMenuMessage(guildConfig?.welcome); if (roleMenuMsg && guildConfig?.welcome?.channelId) { - const welcomeChannel = - interaction.guild.channels.cache.get(guildConfig.welcome.channelId) || - (await interaction.guild.channels.fetch(guildConfig.welcome.channelId).catch(() => null)); + const welcomeChannel = await fetchChannelCached( + interaction.client, + guildConfig.welcome.channelId, + ); if (welcomeChannel?.isTextBased?.()) { await safeSend(welcomeChannel, roleMenuMsg); diff --git a/src/index.js b/src/index.js index bb135721..e931ccbf 100644 --- a/src/index.js +++ b/src/index.js @@ -43,21 +43,17 @@ import { startConversationCleanup, stopConversationCleanup, } from './modules/ai.js'; -import { startScheduledBackups, stopScheduledBackups } from './modules/backup.js'; -import { startBotStatus, stopBotStatus } from './modules/botStatus.js'; -import { loadAliasesFromDb, resolveAlias } from './modules/commandAliases.js'; import { getConfig, loadConfig } from './modules/config.js'; import { registerEventHandlers } from './modules/events.js'; import { startGithubFeed, stopGithubFeed } from './modules/githubFeed.js'; import { checkMem0Health, markUnavailable } from './modules/memory.js'; import { startTempbanScheduler, stopTempbanScheduler } from './modules/moderation.js'; import { loadOptOuts } from './modules/optout.js'; -import { PerformanceMonitor } from './modules/performanceMonitor.js'; import { startScheduler, stopScheduler } from './modules/scheduler.js'; -import { startTempRoleScheduler, stopTempRoleScheduler } from './modules/tempRoleHandler.js'; import { startTriage, stopTriage } from './modules/triage.js'; import { startVoiceFlush, stopVoiceFlush } from './modules/voice.js'; import { fireEventAllGuilds } from './modules/webhookNotifier.js'; +import { closeRedisClient as closeRedis, initRedis } from './redis.js'; import { pruneOldLogs } from './transports/postgres.js'; import { stopCacheCleanup } from './utils/cache.js'; import { @@ -130,36 +126,6 @@ client.commands = new Collection(); // Initialize health monitor const healthMonitor = HealthMonitor.getInstance(); -// Initialize performance monitor (singleton; start() called in ClientReady handler) -const perfMonitor = PerformanceMonitor.getInstance(); - -/** @type {ReturnType | null} Health degraded check interval */ -let healthCheckInterval = null; - -/** - * Start the periodic health degraded check. - * Fires health.degraded webhook when memory >80% or event loop lag >100ms. - * - * @param {string[]} guildIds - Guild IDs to notify - */ -function startHealthDegradedCheck() { - if (healthCheckInterval) return; - healthCheckInterval = setInterval(async () => { - const mem = process.memoryUsage(); - const memRatio = mem.heapTotal > 0 ? mem.heapUsed / mem.heapTotal : 0; - const lag = await measureEventLoopLag(); - const degraded = memRatio > MEMORY_DEGRADED_THRESHOLD || lag > EVENT_LOOP_LAG_THRESHOLD_MS; - if (degraded) { - fireEventAllGuilds('health.degraded', { - memoryUsedMb: Math.round(mem.heapUsed / 1024 / 1024), - memoryTotalMb: Math.round(mem.heapTotal / 1024 / 1024), - memoryRatio: Math.round(memRatio * 100), - eventLoopLagMs: lag, - }).catch(() => {}); - } - }, 60_000).unref(); -} - /** * Save conversation history to disk */ @@ -251,16 +217,10 @@ client.on('interactionCreate', async (interaction) => { try { info('Slash command received', { command: commandName, user: interaction.user.tag }); - // Resolve alias → target command (per-guild custom aliases). - // Do this early so permission checks and command lookup both use the - // resolved (canonical) command name rather than the alias name. - const resolvedCommandName = resolveAlias(interaction.guildId, commandName) || commandName; - - // Permission check (using resolved command name so alias permissions mirror target) + // Permission check const guildConfig = getConfig(interaction.guildId); - if (!hasPermission(member, resolvedCommandName, guildConfig)) { - const permLevel = - guildConfig.permissions?.allowedCommands?.[resolvedCommandName] || 'administrator'; + if (!hasPermission(member, commandName, guildConfig)) { + const permLevel = guildConfig.permissions?.allowedCommands?.[commandName] || 'administrator'; await safeReply(interaction, { content: getPermissionError(commandName, permLevel), ephemeral: true, @@ -270,7 +230,7 @@ client.on('interactionCreate', async (interaction) => { } // Execute command from collection - const command = client.commands.get(resolvedCommandName); + const command = client.commands.get(commandName); if (!command) { await safeReply(interaction, { content: '❌ Command not found.', @@ -279,12 +239,9 @@ client.on('interactionCreate', async (interaction) => { return; } - const _cmdStart = Date.now(); await command.execute(interaction); - perfMonitor.recordResponseTime(commandName, Date.now() - _cmdStart, 'command'); info('Command executed', { - command: resolvedCommandName, - alias: resolvedCommandName !== commandName ? commandName : undefined, + command: commandName, user: interaction.user.tag, guildId: interaction.guildId, channelId: interaction.channelId, @@ -325,13 +282,9 @@ async function gracefulShutdown(signal) { stopTriage(); stopConversationCleanup(); stopTempbanScheduler(); - stopTempRoleScheduler(); stopScheduler(); stopGithubFeed(); - stopScheduledBackups(); - perfMonitor.stop(); - stopBotStatus(); - stopVoiceFlush(); + // 1.5. Stop API server (drain in-flight HTTP requests before closing DB) try { await stopServer(); @@ -381,13 +334,7 @@ async function gracefulShutdown(signal) { info('Disconnecting from Discord'); client.destroy(); - // 7. Stop health check interval - if (healthCheckInterval) { - clearInterval(healthCheckInterval); - healthCheckInterval = null; - } - - // 8. Log clean exit + // 7. Log clean exit info('Shutdown complete'); process.exit(0); } @@ -404,21 +351,14 @@ client.on('error', (err) => { code: err.code, source: 'discord_client', }); - fireEventAllGuilds('bot.error', { message: err.message, code: err.code }).catch(() => {}); }); client.on('shardDisconnect', (event, shardId) => { if (event.code !== 1000) { warn('Shard disconnected unexpectedly', { shardId, code: event.code, source: 'discord_shard' }); - fireEventAllGuilds('bot.disconnected', { shardId, code: event.code }).catch(() => {}); } }); -client.on('shardResume', (shardId, replayedEvents) => { - info('Shard reconnected', { shardId, replayedEvents, source: 'discord_shard' }); - fireEventAllGuilds('bot.reconnected', { shardId, replayedEvents }).catch(() => {}); -}); - // Start bot const token = process.env.DISCORD_TOKEN; if (!token) { @@ -502,11 +442,6 @@ async function startup() { // Load opt-out preferences from DB before enabling memory features await loadOptOuts(); - // Load command aliases from DB into memory cache - if (dbPool) { - await loadAliasesFromDb(dbPool); - } - // Check mem0 availability for user memory features (with timeout to avoid blocking startup). // AbortController prevents a late-resolving health check from calling markAvailable() // after the timeout has already called markUnavailable(). @@ -531,28 +466,20 @@ async function startup() { // Register event handlers with live config reference registerEventHandlers(client, config, healthMonitor); - // Start performance monitor - perfMonitor.start(); - // Start triage module (per-channel message classification + response) await startTriage(client, config, healthMonitor); // Start tempban scheduler for automatic unbans (DB required) if (dbPool) { startTempbanScheduler(client); - startTempRoleScheduler(client); startScheduler(client); startGithubFeed(client); - startScheduledBackups(); - startVoiceFlush(); } + // Load commands and login await loadCommands(); await client.login(token); - // Start bot status/activity rotation (runs after login so client.user is available) - startBotStatus(client); - // Set Sentry context now that we know the bot identity (no-op if disabled) import('./sentry.js') .then(({ Sentry, sentryEnabled }) => { @@ -566,9 +493,6 @@ async function startup() { }) .catch(() => {}); - // Start periodic health degraded check (fires webhooks on threshold breach) - startHealthDegradedCheck(); - // Start REST API server with WebSocket log streaming (non-fatal — bot continues without it) { let wsTransport = null; diff --git a/src/modules/reviewHandler.js b/src/modules/reviewHandler.js index 4f0caffd..726ea0cf 100644 --- a/src/modules/reviewHandler.js +++ b/src/modules/reviewHandler.js @@ -10,6 +10,7 @@ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; import { getPool } from '../db.js'; import { info, warn } from '../logger.js'; +import { fetchChannelCached } from '../utils/discordCache.js'; import { safeReply, safeSend } from '../utils/safeSend.js'; import { getConfig } from './config.js'; @@ -123,7 +124,7 @@ export async function updateReviewMessage(review, client) { if (!review.message_id || !review.channel_id) return; try { - const channel = await client.channels.fetch(review.channel_id).catch(() => null); + const channel = await fetchChannelCached(client, review.channel_id); if (!channel) return; const message = await channel.messages.fetch(review.message_id).catch(() => null); @@ -303,7 +304,7 @@ export async function expireStaleReviews(client) { if (!reviewChannelId) continue; try { - const channel = await client.channels.fetch(reviewChannelId).catch(() => null); + const channel = await fetchChannelCached(client, reviewChannelId); if (!channel) continue; const ids = reviews.map((r) => `#${r.id}`).join(', '); diff --git a/src/modules/triage-respond.js b/src/modules/triage-respond.js index 1de9c8b4..8bf3ca39 100644 --- a/src/modules/triage-respond.js +++ b/src/modules/triage-respond.js @@ -10,44 +10,11 @@ import { fetchChannelCached } from '../utils/discordCache.js'; import { safeSend } from '../utils/safeSend.js'; import { splitMessage } from '../utils/splitMessage.js'; import { addToHistory } from './ai.js'; -import { FEEDBACK_EMOJI, registerAiMessage } from './aiFeedback.js'; -import { isProtectedTarget } from './moderation.js'; import { resolveMessageId, sanitizeText } from './triage-filter.js'; -import { fireEvent } from './webhookNotifier.js'; /** Maximum characters to keep from fetched context messages. */ const CONTEXT_MESSAGE_CHAR_LIMIT = 500; -// ── Feedback reaction helper ───────────────────────────────────────────────── - -/** - * Add 👍/👎 feedback reactions to a sent AI message (fire-and-forget). - * Only runs when ai.feedback.enabled is true in the guild config. - * - * @param {import('discord.js').Message|import('discord.js').Message[]|null} sentMsg - Return value of safeSend. - * @param {Object} config - Bot configuration. - */ -function addFeedbackReactions(sentMsg, config) { - if (!config?.ai?.feedback?.enabled) return; - - const messages = Array.isArray(sentMsg) ? sentMsg : [sentMsg]; - // Only react to the first message chunk to avoid emoji spam on long responses - const first = messages[0]; - if (!first?.id) return; - - registerAiMessage(first.id); - - // Fire-and-forget: never block the response flow - Promise.resolve() - .then(async () => { - await first.react(FEEDBACK_EMOJI.positive); - await first.react(FEEDBACK_EMOJI.negative); - }) - .catch(() => { - // Reaction permission errors are non-fatal - }); -} - // ── History helpers ────────────────────────────────────────────────────────── /** @@ -139,31 +106,6 @@ export async function sendModerationLog(client, classification, snapshot, channe // Find target messages from the snapshot const targets = snapshot.filter((m) => classification.targetMessageIds?.includes(m.messageId)); - // Skip moderation log if any flagged user is a protected role (admin/mod/owner) - const guild = logChannel.guild; - if (guild && targets.length > 0) { - // Skip the expensive member-fetch loop when protection is explicitly disabled. - if (config.moderation?.protectRoles?.enabled !== false) { - const seenUserIds = new Set(); - for (const t of targets) { - if (seenUserIds.has(t.userId)) continue; - seenUserIds.add(t.userId); - try { - const member = await guild.members.fetch(t.userId); - if (isProtectedTarget(member, guild)) { - warn('Triage skipped moderation log: target is a protected role', { - userId: t.userId, - channelId, - }); - return; - } - } catch { - // Member not in guild or fetch failed — proceed with logging - } - } - } - } - const actionLabels = { warn: '\u26A0\uFE0F Warn', timeout: '\uD83D\uDD07 Timeout', @@ -249,15 +191,6 @@ export async function sendResponses( if (type === 'moderate') { warn('Moderation flagged', { channelId, reasoning: classification.reasoning }); - // Fire member.flagged webhook notification - const guildId = channel?.guild?.id; - if (guildId) { - fireEvent('member.flagged', guildId, { - channelId, - reasoning: classification.reasoning?.slice(0, 500), - flaggedUsers: classification.flaggedUsers?.map((u) => u.userId || u) || [], - }).catch(() => {}); - } if (triageConfig.moderationResponse !== false && responses.length > 0) { for (const r of responses) { @@ -310,8 +243,6 @@ export async function sendResponses( const sentMsg = await safeSend(channel, msgOpts); // Log AI response to conversation history logAssistantHistory(channelId, channel.guild?.id || null, chunks[i], sentMsg); - // Add feedback reactions to first chunk only - if (i === 0) addFeedbackReactions(sentMsg, config); } info('Triage response sent', { @@ -364,7 +295,7 @@ export async function buildStatsAndLog( }; // Fetch channel once for guildId resolution + passing to sendResponses - const channel = await fetchChannelCached(client, channelId); + const channel = await fetchChannelCached(client, channelId).catch(() => null); const guildId = channel?.guildId; // Log AI usage analytics (fire-and-forget) diff --git a/src/modules/welcomeOnboarding.js b/src/modules/welcomeOnboarding.js index 1e58ad02..d5d52fad 100644 --- a/src/modules/welcomeOnboarding.js +++ b/src/modules/welcomeOnboarding.js @@ -6,6 +6,7 @@ import { StringSelectMenuBuilder, } from 'discord.js'; import { info } from '../logger.js'; +import { fetchChannelCached } from '../utils/discordCache.js'; import { safeEditReply, safeSend } from '../utils/safeSend.js'; export const RULES_ACCEPT_BUTTON_ID = 'welcome_rules_accept'; @@ -118,7 +119,7 @@ export function buildRoleMenuMessage(welcomeConfig) { } async function fetchRole(guild, roleId) { - return guild.roles.cache.get(roleId) || (await guild.roles.fetch(roleId).catch(() => null)); + return guild.roles.cache.get(roleId) || (await guild.roles.fetch(roleId).catch(() => null)); // roles.cache is in-memory; fetch only on miss } export async function handleRulesAcceptButton(interaction, config) { @@ -173,9 +174,7 @@ export async function handleRulesAcceptButton(interaction, config) { } if (welcome.introChannel) { - const introChannel = - interaction.guild.channels.cache.get(welcome.introChannel) || - (await interaction.guild.channels.fetch(welcome.introChannel).catch(() => null)); + const introChannel = await fetchChannelCached(interaction.client, welcome.introChannel); if (introChannel?.isTextBased?.()) { await safeSend( diff --git a/tests/commands/leaderboard.test.js b/tests/commands/leaderboard.test.js index 19918fae..47c226b6 100644 --- a/tests/commands/leaderboard.test.js +++ b/tests/commands/leaderboard.test.js @@ -14,6 +14,10 @@ vi.mock('../../src/modules/config.js', () => ({ getConfig: vi.fn().mockReturnValue({ reputation: { enabled: true } }), })); +vi.mock('../../src/utils/reputationCache.js', () => ({ + getLeaderboardCached: vi.fn().mockImplementation((_guildId, factory) => factory()), +})); + vi.mock('../../src/utils/safeSend.js', () => ({ safeEditReply: vi.fn(), })); diff --git a/tests/commands/rank.test.js b/tests/commands/rank.test.js index 215e2c0c..d4c04270 100644 --- a/tests/commands/rank.test.js +++ b/tests/commands/rank.test.js @@ -23,6 +23,12 @@ vi.mock('../../src/modules/reputation.js', async (importOriginal) => { }; }); +vi.mock('../../src/utils/reputationCache.js', () => ({ + getReputationCached: vi.fn().mockResolvedValue(null), // always cache miss → hits DB + setReputationCache: vi.fn().mockResolvedValue(undefined), + getRankCached: vi.fn().mockImplementation((_guildId, _userId, factory) => factory()), +})); + vi.mock('../../src/utils/safeSend.js', () => ({ safeEditReply: vi.fn(), })); diff --git a/tests/commands/review.test.js b/tests/commands/review.test.js index 9ce9ecd2..fc5d77c0 100644 --- a/tests/commands/review.test.js +++ b/tests/commands/review.test.js @@ -27,6 +27,16 @@ vi.mock('../../src/utils/safeSend.js', () => ({ safeEditReply: vi.fn((t, opts) => t.editReply(opts)), })); +vi.mock('../../src/utils/discordCache.js', () => ({ + fetchChannelCached: vi + .fn() + .mockImplementation((client, channelId) => client.channels.fetch(channelId).catch(() => null)), + fetchGuildChannelsCached: vi.fn().mockResolvedValue([]), + fetchGuildRolesCached: vi.fn().mockResolvedValue([]), + fetchMemberCached: vi.fn().mockResolvedValue(null), + invalidateGuildCache: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('discord.js', () => { function chainable() { const proxy = new Proxy(() => proxy, { diff --git a/tests/modules/welcomeOnboarding.test.js b/tests/modules/welcomeOnboarding.test.js index 3d0cfda6..e8864155 100644 --- a/tests/modules/welcomeOnboarding.test.js +++ b/tests/modules/welcomeOnboarding.test.js @@ -6,6 +6,18 @@ vi.mock('../../src/logger.js', () => ({ warn: vi.fn(), })); +vi.mock('../../src/utils/discordCache.js', () => ({ + fetchChannelCached: vi.fn().mockImplementation((client, channelId) => { + if (!channelId) return Promise.resolve(null); + // Use client.channels.cache if available + if (client?.channels?.cache?.get?.(channelId)) { + return Promise.resolve(client.channels.cache.get(channelId)); + } + return client?.channels?.fetch?.(channelId).catch(() => null) ?? Promise.resolve(null); + }), + invalidateGuildCache: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('../../src/utils/safeSend.js', () => ({ safeSend: vi.fn(async (target, payload) => { if (typeof target?.send === 'function') return target.send(payload); @@ -80,6 +92,12 @@ describe('welcomeOnboarding module', () => { fetch: vi.fn(async () => introChannel), }, }, + client: { + channels: { + cache: new Map([['intro-ch', introChannel]]), + fetch: vi.fn(async () => introChannel), + }, + }, reply: vi.fn(async () => {}), deferReply: vi.fn(async () => {}), editReply: vi.fn(async () => {}),