diff --git a/README.md b/README.md index fd94106c..9cccbbc9 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,7 @@ OmegaBot is online │ │ ├── discord │ │ │ ├── commandLoader.ts │ │ │ ├── commandMeta.ts +│ │ │ ├── cooldowns.ts │ │ │ ├── fetchChannelMessages.ts │ │ │ └── interactionHandler.ts │ │ ├── faq diff --git a/src/services/discord/cooldowns.ts b/src/services/discord/cooldowns.ts new file mode 100644 index 00000000..0e059c08 --- /dev/null +++ b/src/services/discord/cooldowns.ts @@ -0,0 +1,41 @@ +// src/services/discord/cooldowns.ts + +/** + * In-memory cooldown store. + * + * Key format: `${userId}:${commandName}` + * Value: timestamp (ms) when the command can be used again + */ +const cooldowns = new Map(); + +function makeKey(userId: string, commandName: string): string { + return `${userId}:${commandName}`; +} + +/** + * Returns remaining cooldown time in milliseconds. + * Returns 0 if no cooldown is active. + */ +export function getCooldownRemainingMs(userId: string, commandName: string): number { + const key = makeKey(userId, commandName); + const expiresAt = cooldowns.get(key); + + if (!expiresAt) return 0; + + const remaining = expiresAt - Date.now(); + return remaining > 0 ? remaining : 0; +} + +/** + * Sets a cooldown for a user + command. + */ +export function setCooldown( + userId: string, + commandName: string, + durationMs: number, +): void { + if (durationMs <= 0) return; + + const key = makeKey(userId, commandName); + cooldowns.set(key, Date.now() + durationMs); +} diff --git a/src/services/discord/interactionHandler.ts b/src/services/discord/interactionHandler.ts index 0808bd00..75571a4d 100644 --- a/src/services/discord/interactionHandler.ts +++ b/src/services/discord/interactionHandler.ts @@ -17,6 +17,23 @@ import type { Interaction, ChatInputCommandInteraction } from "discord.js"; import type { CommandClient } from "./commandLoader.js"; import { logger } from "../../utils/logger.js"; +import { getCooldownRemainingMs, setCooldown } from "./cooldowns.js"; + +/** + * Per-command cooldown durations (ms). + * + * Keep this list small and focused: + * - Only expensive commands should be throttled (LLM calls, external APIs). + * - Defaults to 0 (no cooldown) for commands not listed here. + * + * Adjust as needed. + */ +const COOLDOWN_MS: Record = { + summary: 30_000, + // github lookups (example) + // gh: 5_000, + // pr: 5_000, +}; /** * Handle a single Discord interaction. @@ -73,6 +90,31 @@ export async function handleInteraction( return; } + /** + * Cooldowns (per-user, per-command) + * + * Cooldown is enforced here so every command benefits consistently. + * We set cooldown BEFORE execution to prevent spam even if the command fails. + */ + const userId = interaction.user.id; + const commandName = interaction.commandName; + + const remainingMs = getCooldownRemainingMs(userId, commandName); + if (remainingMs > 0) { + const seconds = Math.ceil(remainingMs / 1000); + + await interaction.reply({ + content: `⏳ That command is on cooldown. Try again in ${seconds}s.`, + ephemeral: true, + }); + return; + } + + const durationMs = COOLDOWN_MS[commandName] ?? 0; + if (durationMs > 0) { + setCooldown(userId, commandName, durationMs); + } + /** * Execute the command safely. *