From 25db18bf35070d493c6614d460f767b095a0357f Mon Sep 17 00:00:00 2001 From: Pip Build Date: Thu, 26 Feb 2026 21:32:46 -0500 Subject: [PATCH 1/2] feat: /tldr conversation summarizer (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a /tldr slash command that uses Claude Haiku to AI-summarize recent Discord channel messages into a rich embed. Features: - /tldr — summarize last 50 messages (default) - /tldr count: — summarize last N messages (max 200) - /tldr hours: — summarize last N hours of messages - Rich embed: Key Topics, Decisions Made, Action Items, Notable Links - Per-channel rate limiting (1 use per 5 minutes) - tldr.enabled config gate via getConfig() - Ephemeral responses via deferReply + safeEditReply Also adds: - @anthropic-ai/sdk as a direct dependency - tldr defaults to config.json (enabled: false) - 11 tests covering all core behaviors Closes #25 --- config.json | 18 +- package.json | 3 +- src/commands/help.js | 4 +- src/commands/tldr.js | 275 ++++++++++++++++++++++++++++ tests/commands/tldr.test.js | 352 ++++++++++++++++++++++++++++++++++++ 5 files changed, 640 insertions(+), 12 deletions(-) create mode 100644 src/commands/tldr.js create mode 100644 tests/commands/tldr.test.js diff --git a/config.json b/config.json index b5dd63bf..351b5a71 100644 --- a/config.json +++ b/config.json @@ -102,9 +102,9 @@ }, "starboard": { "enabled": false, - "channelId": "", + "channelId": null, "threshold": 3, - "emoji": "⭐", + "emoji": "*", "selfStarAllowed": false, "ignoredChannels": [] }, @@ -119,14 +119,6 @@ "flushIntervalMs": 5000 } }, - "starboard": { - "enabled": false, - "channelId": null, - "threshold": 3, - "emoji": "*", - "selfStarAllowed": false, - "ignoredChannels": [] - }, "permissions": { "enabled": true, "adminRoleId": null, @@ -154,5 +146,11 @@ "modlog": "moderator", "announce": "moderator" } + }, + "tldr": { + "enabled": false, + "defaultMessages": 50, + "maxMessages": 200, + "cooldownSeconds": 300 } } \ No newline at end of file diff --git a/package.json b/package.json index 931a5731..ed4b51e5 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", "winston-transport": "^4.9.0", - "ws": "^8.19.0" + "ws": "^8.19.0", + "@anthropic-ai/sdk": "^0.78.0" }, "pnpm": { "overrides": { diff --git a/src/commands/help.js b/src/commands/help.js index b6760387..9e74c252 100644 --- a/src/commands/help.js +++ b/src/commands/help.js @@ -64,7 +64,9 @@ export const data = new SlashCommandBuilder() .setRequired(true) .setAutocomplete(true), ) - .addStringOption((opt) => opt.setName('title').setDescription('New title').setMaxLength(256).setRequired(false)) + .addStringOption((opt) => + opt.setName('title').setDescription('New title').setMaxLength(256).setRequired(false), + ) .addStringOption((opt) => opt.setName('content').setDescription('New content').setMaxLength(4096).setRequired(false), ), diff --git a/src/commands/tldr.js b/src/commands/tldr.js new file mode 100644 index 00000000..c062a471 --- /dev/null +++ b/src/commands/tldr.js @@ -0,0 +1,275 @@ +/** + * TLDR Command + * AI-powered conversation summarizer for Discord channels. + * Summarizes recent messages into key topics, decisions, action items, and links. + */ + +import Anthropic from '@anthropic-ai/sdk'; +import { EmbedBuilder, SlashCommandBuilder } from 'discord.js'; +import { info, error as logError } from '../logger.js'; +import { getConfig } from '../modules/config.js'; +import { safeEditReply } from '../utils/safeSend.js'; + +/** Colour for TLDR embeds (teal-ish) */ +const EMBED_COLOR = 0x1abc9c; + +/** Max chars to send to Claude to stay within context limits */ +const MAX_INPUT_CHARS = 100_000; + +/** Default number of messages to summarize */ +const DEFAULT_MESSAGE_COUNT = 50; + +/** Hard cap on messages fetchable */ +const MAX_MESSAGE_COUNT = 200; + +/** Cooldown tracking: channelId → last-used timestamp (ms) */ +const cooldownMap = new Map(); + +/** Claude model for cost-efficient summarization */ +const SUMMARIZE_MODEL = 'claude-haiku-4-5'; + +/** System prompt for summarization */ +const SYSTEM_PROMPT = + 'Summarize this Discord conversation. Extract: 1) Key topics discussed, 2) Decisions made, 3) Action items, 4) Notable links shared. Be concise.'; + +export const data = new SlashCommandBuilder() + .setName('tldr') + .setDescription('Summarize recent channel messages with AI') + .addIntegerOption((opt) => + opt + .setName('count') + .setDescription( + `Number of messages to summarize (default ${DEFAULT_MESSAGE_COUNT}, max ${MAX_MESSAGE_COUNT})`, + ) + .setMinValue(1) + .setMaxValue(MAX_MESSAGE_COUNT) + .setRequired(false), + ) + .addIntegerOption((opt) => + opt + .setName('hours') + .setDescription('Summarize messages from the last N hours') + .setMinValue(1) + .setMaxValue(168) + .setRequired(false), + ); + +/** + * Check if the channel is on cooldown. + * @param {string} channelId + * @param {number} cooldownSeconds + * @returns {{ onCooldown: boolean, remainingSeconds: number }} + */ +function checkCooldown(channelId, cooldownSeconds) { + const last = cooldownMap.get(channelId); + if (!last) return { onCooldown: false, remainingSeconds: 0 }; + const elapsed = (Date.now() - last) / 1000; + if (elapsed >= cooldownSeconds) return { onCooldown: false, remainingSeconds: 0 }; + return { onCooldown: true, remainingSeconds: Math.ceil(cooldownSeconds - elapsed) }; +} + +/** + * Format a Date as HH:MM in UTC. + * @param {Date} date + * @returns {string} + */ +function formatTime(date) { + const h = String(date.getUTCHours()).padStart(2, '0'); + const m = String(date.getUTCMinutes()).padStart(2, '0'); + return `${h}:${m}`; +} + +/** + * Fetch and format messages from the channel. + * @param {import('discord.js').TextBasedChannel} channel + * @param {{ count?: number, hours?: number, defaultMessages: number, maxMessages: number }} opts + * @returns {Promise<{ text: string, messageCount: number }>} + */ +async function fetchAndFormatMessages(channel, opts) { + const { count, hours, defaultMessages, maxMessages } = opts; + + if (hours != null) { + // Fetch recent messages and filter by time window + const cutoff = Date.now() - hours * 60 * 60 * 1000; + const fetched = await channel.messages.fetch({ limit: maxMessages }); + const sorted = [...fetched.values()] + .filter((m) => m.createdTimestamp >= cutoff) + .sort((a, b) => a.createdTimestamp - b.createdTimestamp); + + const lines = sorted + .filter((m) => m.content && !m.author.bot) + .map((m) => `[${formatTime(m.createdAt)}] ${m.author.username}: ${m.content}`); + + return { text: lines.join('\n'), messageCount: lines.length }; + } + + // Fetch by count + const limit = Math.min(count ?? defaultMessages, maxMessages); + const fetched = await channel.messages.fetch({ limit }); + const sorted = [...fetched.values()].sort((a, b) => a.createdTimestamp - b.createdTimestamp); + + const lines = sorted + .filter((m) => m.content && !m.author.bot) + .map((m) => `[${formatTime(m.createdAt)}] ${m.author.username}: ${m.content}`); + + return { text: lines.join('\n'), messageCount: lines.length }; +} + +/** + * Call Claude to summarize a conversation. + * @param {string} conversationText + * @returns {Promise} Raw summary text from Claude + */ +async function summarizeWithAI(conversationText) { + const client = new Anthropic(); + const truncated = conversationText.slice(0, MAX_INPUT_CHARS); + + const response = await client.messages.create({ + model: SUMMARIZE_MODEL, + max_tokens: 1024, + system: SYSTEM_PROMPT, + messages: [{ role: 'user', content: truncated }], + }); + + return response.content[0]?.text ?? ''; +} + +/** + * Parse the AI summary into structured sections. + * Handles numbered lists like "1) Key topics discussed" or "1. Key topics" + * and extracts bullet points under each section. + * @param {string} summary + * @returns {{ topics: string, decisions: string, actions: string, links: string }} + */ +function parseSummary(summary) { + const sections = { + topics: 'No key topics identified.', + decisions: 'No decisions made.', + actions: 'No action items.', + links: 'No notable links.', + }; + + // Split on numbered section headers (e.g. "1)", "1.", "**1)") + const sectionRegex = + /(?:^|\n)\*{0,2}(?:\d+[.)]\s*)?(?:\*{0,2})(Key Topics?|Decisions? Made|Action Items?|Notable Links?)(?:\*{0,2})[:.]?\*{0,2}/gi; + + const parts = summary.split(sectionRegex); + // parts will be: [preamble, header1, body1, header2, body2, ...] + for (let i = 1; i < parts.length - 1; i += 2) { + const header = parts[i].trim().toLowerCase(); + const body = parts[i + 1]?.trim() ?? ''; + const cleaned = body + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + .join('\n'); + + if (header.includes('topic')) sections.topics = cleaned || sections.topics; + else if (header.includes('decision')) sections.decisions = cleaned || sections.decisions; + else if (header.includes('action')) sections.actions = cleaned || sections.actions; + else if (header.includes('link')) sections.links = cleaned || sections.links; + } + + return sections; +} + +/** + * Build the rich embed from the AI summary. + * @param {string} summary + * @param {number} messageCount + * @param {string} channelName + * @returns {import('discord.js').EmbedBuilder} + */ +function buildEmbed(summary, messageCount, channelName) { + const { topics, decisions, actions, links } = parseSummary(summary); + + const truncate = (str, max = 1024) => (str.length > max ? `${str.slice(0, max - 3)}...` : str); + + return new EmbedBuilder() + .setColor(EMBED_COLOR) + .setTitle('📋 TL;DR Summary') + .setDescription( + `Summarized **${messageCount}** message${messageCount === 1 ? '' : 's'} in #${channelName}`, + ) + .addFields( + { name: '🗝️ Key Topics', value: truncate(topics) }, + { name: '✅ Decisions Made', value: truncate(decisions) }, + { name: '📌 Action Items', value: truncate(actions) }, + { name: '🔗 Notable Links', value: truncate(links) }, + ) + .setTimestamp(); +} + +/** + * Execute the /tldr command. + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +export async function execute(interaction) { + await interaction.deferReply({ ephemeral: true }); + + try { + const guildId = interaction.guildId; + const config = getConfig(guildId); + const tldrConfig = config?.tldr ?? {}; + + // Check if feature is enabled + if (tldrConfig.enabled === false) { + return await safeEditReply( + interaction, + '❌ The /tldr command is not enabled on this server.', + ); + } + + const cooldownSeconds = tldrConfig.cooldownSeconds ?? 300; + const defaultMessages = tldrConfig.defaultMessages ?? DEFAULT_MESSAGE_COUNT; + const maxMessages = tldrConfig.maxMessages ?? MAX_MESSAGE_COUNT; + const channelId = interaction.channelId; + + // Rate limit check + const { onCooldown, remainingSeconds } = checkCooldown(channelId, cooldownSeconds); + if (onCooldown) { + return await safeEditReply( + interaction, + `⏳ Please wait **${remainingSeconds}s** before using /tldr again in this channel.`, + ); + } + + const channel = interaction.channel; + const count = interaction.options.getInteger('count'); + const hours = interaction.options.getInteger('hours'); + + // Fetch and format messages + const { text: conversationText, messageCount } = await fetchAndFormatMessages(channel, { + count, + hours, + defaultMessages, + maxMessages, + }); + + if (messageCount === 0) { + return await safeEditReply(interaction, '❌ No messages found to summarize.'); + } + + info('TLDR summarizing', { guildId, channelId, messageCount }); + + // Mark cooldown before AI call + cooldownMap.set(channelId, Date.now()); + + // Call AI + const summary = await summarizeWithAI(conversationText); + + if (!summary) { + return await safeEditReply(interaction, '❌ Failed to generate summary.'); + } + + // Build embed + const embed = buildEmbed(summary, messageCount, channel.name ?? channelId); + + await safeEditReply(interaction, { embeds: [embed] }); + } catch (err) { + logError('TLDR command failed', { error: err.message, stack: err.stack }); + await safeEditReply(interaction, '❌ Failed to summarize messages. Please try again later.'); + } +} + +export { cooldownMap }; diff --git a/tests/commands/tldr.test.js b/tests/commands/tldr.test.js new file mode 100644 index 00000000..2da90c0a --- /dev/null +++ b/tests/commands/tldr.test.js @@ -0,0 +1,352 @@ +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/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ + tldr: { enabled: true, defaultMessages: 50, maxMessages: 200, cooldownSeconds: 300 }, + }), +})); + +vi.mock('../../src/utils/safeSend.js', () => ({ + safeEditReply: vi.fn((interaction, opts) => interaction.editReply(opts)), +})); + +// Mock @anthropic-ai/sdk +const mockCreate = vi.fn(); +vi.mock('@anthropic-ai/sdk', () => { + const MockAnthropic = function MockAnthropic() { + return { messages: { create: mockCreate } }; + }; + return { default: MockAnthropic }; +}); + +// Mock discord.js builders +vi.mock('discord.js', () => { + function chainable() { + const proxy = new Proxy(() => proxy, { + get: () => () => proxy, + apply: () => proxy, + }); + return proxy; + } + + class MockSlashCommandBuilder { + constructor() { + this.name = ''; + this.description = ''; + } + setName(n) { + this.name = n; + return this; + } + setDescription(d) { + this.description = d; + return this; + } + addIntegerOption(fn) { + fn(chainable()); + return this; + } + toJSON() { + return { name: this.name, description: this.description }; + } + } + + class MockEmbedBuilder { + constructor() { + this._data = { fields: [] }; + } + setColor(c) { + this._data.color = c; + return this; + } + setTitle(t) { + this._data.title = t; + return this; + } + setDescription(d) { + this._data.description = d; + return this; + } + addFields(...fields) { + this._data.fields.push(...fields.flat()); + return this; + } + setTimestamp() { + return this; + } + setFooter(f) { + this._data.footer = f; + return this; + } + getData() { + return this._data; + } + } + + return { + SlashCommandBuilder: MockSlashCommandBuilder, + EmbedBuilder: MockEmbedBuilder, + }; +}); + +// ── Helpers ──────────────────────────────────────────────────────────────── + +import { getConfig } from '../../src/modules/config.js'; + +/** + * Build a fake Discord message. + */ +function makeMessage(content, username = 'TestUser', createdAt = new Date()) { + return { + content, + author: { username, bot: false }, + createdAt, + createdTimestamp: createdAt.getTime(), + }; +} + +/** + * Build a mock interaction. + */ +function createInteraction({ count = null, hours = null } = {}) { + const now = Date.now(); + const messages = new Map(); + for (let i = 0; i < 60; i++) { + const id = String(i); + messages.set(id, makeMessage(`Message ${i}`, 'User', new Date(now - i * 60_000))); + } + + return { + guildId: 'guild-1', + channelId: 'channel-1', + channel: { + name: 'general', + messages: { + fetch: vi.fn().mockResolvedValue(messages), + }, + }, + options: { + getInteger: vi.fn((name) => (name === 'count' ? count : name === 'hours' ? hours : null)), + }, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + }; +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +import { cooldownMap, data, execute } from '../../src/commands/tldr.js'; + +beforeEach(() => { + cooldownMap.clear(); + vi.clearAllMocks(); + + // Reset config mock to enabled + getConfig.mockReturnValue({ + tldr: { enabled: true, defaultMessages: 50, maxMessages: 200, cooldownSeconds: 300 }, + }); + + // Default AI response + mockCreate.mockResolvedValue({ + content: [ + { + text: 'Key Topics\nSome topic\n\nDecisions Made\nSome decision\n\nAction Items\nSome action\n\nNotable Links\nhttp://example.com', + }, + ], + }); +}); + +afterEach(() => { + cooldownMap.clear(); +}); + +describe('tldr command data', () => { + it('has correct name and description', () => { + expect(data.name).toBe('tldr'); + expect(data.description).toContain('Summarize'); + }); +}); + +describe('execute — default 50 message fetch', () => { + it('fetches with limit 50 by default', async () => { + const interaction = createInteraction(); + await execute(interaction); + + expect(interaction.channel.messages.fetch).toHaveBeenCalledWith({ limit: 50 }); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array) }), + ); + }); +}); + +describe('execute — count option', () => { + it('fetches with specified count', async () => { + const interaction = createInteraction({ count: 100 }); + await execute(interaction); + + expect(interaction.channel.messages.fetch).toHaveBeenCalledWith({ limit: 100 }); + }); + + it('caps count at maxMessages', async () => { + getConfig.mockReturnValue({ + tldr: { enabled: true, defaultMessages: 50, maxMessages: 200, cooldownSeconds: 300 }, + }); + const interaction = createInteraction({ count: 999 }); + // getInteger for 'count' returns 999, but buildEmbed should use 200 + // The command clamps to maxMessages + await execute(interaction); + expect(interaction.channel.messages.fetch).toHaveBeenCalledWith({ limit: 200 }); + }); +}); + +describe('execute — hours option', () => { + it('filters messages by time window', async () => { + const now = Date.now(); + const oneHourAgo = now - 60 * 60 * 1000; + + // 10 recent messages (within 1h) + 10 old messages + const messages = new Map(); + for (let i = 0; i < 10; i++) { + messages.set(`new-${i}`, makeMessage(`New ${i}`, 'U', new Date(now - i * 60_000))); + } + for (let i = 0; i < 10; i++) { + messages.set( + `old-${i}`, + makeMessage(`Old ${i}`, 'U', new Date(oneHourAgo - (i + 1) * 60_000)), + ); + } + + const interaction = createInteraction({ hours: 1 }); + interaction.channel.messages.fetch = vi.fn().mockResolvedValue(messages); + + await execute(interaction); + + // Embed should be called; only 10 new messages should be used + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array) }), + ); + + // Verify AI was called (only triggered when messages > 0) + expect(mockCreate).toHaveBeenCalled(); + + // The conversation text sent to AI should only contain "New" messages + const aiCallArg = mockCreate.mock.calls[0][0]; + const userContent = aiCallArg.messages[0].content; + expect(userContent).toContain('New 0'); + expect(userContent).not.toContain('Old 0'); + }); +}); + +describe('execute — cooldown', () => { + it('enforces cooldown on second call within window', async () => { + const interaction = createInteraction(); + await execute(interaction); + + // Second call in same channel — should be rate-limited + interaction.deferReply.mockClear(); + interaction.editReply.mockClear(); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('Please wait')); + }); + + it('allows second call after cooldown expires', async () => { + const interaction = createInteraction(); + + // Manually put a stale timestamp + cooldownMap.set('channel-1', Date.now() - 400_000); // 400s ago (> 300s cooldown) + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array) }), + ); + }); +}); + +describe('execute — disabled config', () => { + it('returns error when tldr is disabled', async () => { + getConfig.mockReturnValue({ + tldr: { enabled: false }, + }); + const interaction = createInteraction(); + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('not enabled')); + expect(mockCreate).not.toHaveBeenCalled(); + }); +}); + +describe('execute — empty channel', () => { + it('handles channel with no user messages gracefully', async () => { + const interaction = createInteraction(); + // All bot messages — filtered out + const botMessages = new Map([ + [ + '1', + { + content: 'bot msg', + author: { username: 'Bot', bot: true }, + createdAt: new Date(), + createdTimestamp: Date.now(), + }, + ], + ]); + interaction.channel.messages.fetch = vi.fn().mockResolvedValue(botMessages); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('No messages found'), + ); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it('handles empty fetch result gracefully', async () => { + const interaction = createInteraction(); + interaction.channel.messages.fetch = vi.fn().mockResolvedValue(new Map()); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('No messages found'), + ); + }); +}); + +describe('execute — AI response formatted into embed', () => { + it('builds embed with all four sections', async () => { + mockCreate.mockResolvedValue({ + content: [ + { + text: '1) Key Topics\n- Deployment pipeline\n- CI/CD fixes\n\n2) Decisions Made\n- Use GitHub Actions\n\n3) Action Items\n- Set up workflow\n\n4) Notable Links\n- https://github.com/actions', + }, + ], + }); + + const interaction = createInteraction(); + await execute(interaction); + + const call = interaction.editReply.mock.calls[0][0]; + expect(call).toHaveProperty('embeds'); + const embed = call.embeds[0]; + expect(embed).toBeDefined(); + + // Check that fields contain expected content + const fields = embed._data?.fields ?? embed.data?.fields ?? []; + const names = fields.map((f) => f.name); + expect(names.some((n) => n.includes('Key Topics'))).toBe(true); + expect(names.some((n) => n.includes('Decisions'))).toBe(true); + expect(names.some((n) => n.includes('Action Items'))).toBe(true); + expect(names.some((n) => n.includes('Notable Links'))).toBe(true); + }); +}); From bbad921a8ed9f9e0146e61c17b317708366cad90 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Thu, 26 Feb 2026 21:50:23 -0500 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?cooldown=20leak,=20module-level=20Anthropic=20client,=20cooldow?= =?UTF-8?q?n=20timing,=20tldr=20permissions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.json | 3 ++- src/commands/tldr.js | 25 +++++++++++++++++++------ tests/commands/tldr.test.js | 2 +- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/config.json b/config.json index 351b5a71..75ad78b4 100644 --- a/config.json +++ b/config.json @@ -144,7 +144,8 @@ "unlock": "admin", "slowmode": "admin", "modlog": "moderator", - "announce": "moderator" + "announce": "moderator", + "tldr": "everyone" } }, "tldr": { diff --git a/src/commands/tldr.js b/src/commands/tldr.js index c062a471..0737592f 100644 --- a/src/commands/tldr.js +++ b/src/commands/tldr.js @@ -25,6 +25,9 @@ const MAX_MESSAGE_COUNT = 200; /** Cooldown tracking: channelId → last-used timestamp (ms) */ const cooldownMap = new Map(); +/** Shared Anthropic client (connection pooling, auth caching). */ +const anthropicClient = new Anthropic(); + /** Claude model for cost-efficient summarization */ const SUMMARIZE_MODEL = 'claude-haiku-4-5'; @@ -64,10 +67,21 @@ function checkCooldown(channelId, cooldownSeconds) { const last = cooldownMap.get(channelId); if (!last) return { onCooldown: false, remainingSeconds: 0 }; const elapsed = (Date.now() - last) / 1000; - if (elapsed >= cooldownSeconds) return { onCooldown: false, remainingSeconds: 0 }; + if (elapsed >= cooldownSeconds) { + cooldownMap.delete(channelId); + return { onCooldown: false, remainingSeconds: 0 }; + } return { onCooldown: true, remainingSeconds: Math.ceil(cooldownSeconds - elapsed) }; } +// Evict stale cooldown entries every 10 minutes (prevent unbounded growth) +setInterval(() => { + const cutoff = Date.now() - 3_600_000; // 1 hour + for (const [id, ts] of cooldownMap) { + if (ts < cutoff) cooldownMap.delete(id); + } +}, 600_000).unref(); + /** * Format a Date as HH:MM in UTC. * @param {Date} date @@ -121,10 +135,9 @@ async function fetchAndFormatMessages(channel, opts) { * @returns {Promise} Raw summary text from Claude */ async function summarizeWithAI(conversationText) { - const client = new Anthropic(); const truncated = conversationText.slice(0, MAX_INPUT_CHARS); - const response = await client.messages.create({ + const response = await anthropicClient.messages.create({ model: SUMMARIZE_MODEL, max_tokens: 1024, system: SYSTEM_PROMPT, @@ -252,9 +265,6 @@ export async function execute(interaction) { info('TLDR summarizing', { guildId, channelId, messageCount }); - // Mark cooldown before AI call - cooldownMap.set(channelId, Date.now()); - // Call AI const summary = await summarizeWithAI(conversationText); @@ -262,6 +272,9 @@ export async function execute(interaction) { return await safeEditReply(interaction, '❌ Failed to generate summary.'); } + // Mark cooldown only after successful AI response + cooldownMap.set(channelId, Date.now()); + // Build embed const embed = buildEmbed(summary, messageCount, channel.name ?? channelId); diff --git a/tests/commands/tldr.test.js b/tests/commands/tldr.test.js index 2da90c0a..708db9b0 100644 --- a/tests/commands/tldr.test.js +++ b/tests/commands/tldr.test.js @@ -19,7 +19,7 @@ vi.mock('../../src/utils/safeSend.js', () => ({ })); // Mock @anthropic-ai/sdk -const mockCreate = vi.fn(); +const { mockCreate } = vi.hoisted(() => ({ mockCreate: vi.fn() })); vi.mock('@anthropic-ai/sdk', () => { const MockAnthropic = function MockAnthropic() { return { messages: { create: mockCreate } };