diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bc9734a8..668bc6ace 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,7 +137,7 @@ jobs: uses: actions/cache@v5 with: path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('web/pnpm-lock.yaml') }} + key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | playwright-${{ runner.os }}- diff --git a/biome.json b/biome.json index 849c186af..a6000ba78 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.10/schema.json", "files": { "includes": [ "src/**/*.js", diff --git a/src/modules/actions/buildPayload.js b/src/modules/actions/buildPayload.js index 713f8c50b..4ba98dce1 100644 --- a/src/modules/actions/buildPayload.js +++ b/src/modules/actions/buildPayload.js @@ -29,7 +29,8 @@ export function buildPayload(action, templateContext) { if (embedConfig.description) embed.setDescription(renderTemplate(embedConfig.description, templateContext)); if (embedConfig.color) embed.setColor(embedConfig.color); - if (embedConfig.thumbnail) embed.setThumbnail(renderTemplate(embedConfig.thumbnail, templateContext)); + if (embedConfig.thumbnail) + embed.setThumbnail(renderTemplate(embedConfig.thumbnail, templateContext)); if (embedConfig.footer) embed.setFooter({ text: renderTemplate(embedConfig.footer, templateContext) }); payload.embeds = [embed]; diff --git a/src/modules/actions/xpBonus.js b/src/modules/actions/xpBonus.js index 36e3b540b..ce5d0ac93 100644 --- a/src/modules/actions/xpBonus.js +++ b/src/modules/actions/xpBonus.js @@ -65,10 +65,11 @@ export async function handleXpBonus(action, context) { activeXpBonusGrants.add(key); try { const pool = getPool(); - await pool.query( - 'UPDATE reputation SET xp = xp + $1 WHERE guild_id = $2 AND user_id = $3', - [amount, guildId, userId], - ); + await pool.query('UPDATE reputation SET xp = xp + $1 WHERE guild_id = $2 AND user_id = $3', [ + amount, + guildId, + userId, + ]); info('xpBonus granted', { guildId, userId, amount }); } finally { diff --git a/src/modules/ai.js b/src/modules/ai.js index 6d2bb6384..1588a9f82 100644 --- a/src/modules/ai.js +++ b/src/modules/ai.js @@ -221,7 +221,7 @@ function hydrateHistory(channelId) { const limit = getHistoryLength(); const hydrationPromise = pool .query( - `SELECT role, content FROM conversations + `SELECT role, content, created_at FROM conversations WHERE channel_id = $1 ORDER BY created_at DESC LIMIT $2`, @@ -232,6 +232,7 @@ function hydrateHistory(channelId) { const dbHistory = rows.reverse().map((row) => ({ role: row.role, content: row.content, + timestamp: row.created_at ? new Date(row.created_at).getTime() : Date.now(), })); // Merge DB history with any messages added while hydration was in-flight. @@ -373,6 +374,7 @@ export async function initConversationHistory() { hydratedByChannel.get(channelId).push({ role: row.role, content: row.content, + timestamp: row.created_at ? new Date(row.created_at).getTime() : Date.now(), }); } diff --git a/src/modules/levelUpActions.js b/src/modules/levelUpActions.js index 992d04ece..9f172e181 100644 --- a/src/modules/levelUpActions.js +++ b/src/modules/levelUpActions.js @@ -13,9 +13,9 @@ import { handleGrantRole } from './actions/grantRole.js'; import { handleNickPrefix, handleNickSuffix } from './actions/nickPrefix.js'; import { handleRemoveRole } from './actions/removeRole.js'; import { checkRoleRateLimit, collectXpManagedRoles } from './actions/roleUtils.js'; +import { handleSendDm } from './actions/sendDm.js'; import { handleWebhook } from './actions/webhook.js'; import { handleXpBonus } from './actions/xpBonus.js'; -import { handleSendDm } from './actions/sendDm.js'; /** * Action handler registry: action type → async handler function. diff --git a/src/modules/triage-parse.js b/src/modules/triage-parse.js index 7f9c8348b..debb57daa 100644 --- a/src/modules/triage-parse.js +++ b/src/modules/triage-parse.js @@ -74,7 +74,7 @@ export function parseSDKResult(raw, channelId, label) { export function parseClassifyResult(sdkMessage, channelId) { const parsed = parseSDKResult(sdkMessage.result, channelId, 'Classifier'); - if (!parsed || !parsed.classification) { + if (!parsed?.classification) { warn('Classifier result unparseable', { channelId, resultType: typeof sdkMessage.result, diff --git a/src/modules/triage.js b/src/modules/triage.js index 447f65056..d1029abe8 100644 --- a/src/modules/triage.js +++ b/src/modules/triage.js @@ -325,7 +325,7 @@ async function runResponder( ); const parsed = parseRespondResult(respondMessage, channelId); - if (!parsed || !parsed.responses?.length) { + if (!parsed?.responses?.length) { warn('Responder returned no responses', { channelId }); return null; } @@ -538,7 +538,15 @@ async function evaluateAndRespond(channelId, snapshot, evalConfig, evalClient) { ).catch((err) => debug('Moderation log fire-and-forget failed', { error: err.message })); } - const didSend = await sendResponses(channel, parsed, classification, snapshot, evalConfig, stats, channelId); + const didSend = await sendResponses( + channel, + parsed, + classification, + snapshot, + evalConfig, + stats, + channelId, + ); // Record response timestamp for cooldown tracking — only if we actually sent something if (didSend) setLastResponseAt(channelId); diff --git a/src/modules/welcomeOnboarding.js b/src/modules/welcomeOnboarding.js index d5d52fad5..605bafde7 100644 --- a/src/modules/welcomeOnboarding.js +++ b/src/modules/welcomeOnboarding.js @@ -231,7 +231,7 @@ export async function handleRoleMenuSelection(interaction, config) { for (const roleId of configuredRoleIds) { const role = await fetchRole(interaction.guild, roleId); - if (!role || !role.editable) continue; + if (!role?.editable) continue; const hasRole = member.roles.cache.has(role.id); if (selectedIds.has(role.id) && !hasRole) addable.push(role); diff --git a/tests/api/utils/configValidation.test.js b/tests/api/utils/configValidation.test.js index b0da2384b..4219342c1 100644 --- a/tests/api/utils/configValidation.test.js +++ b/tests/api/utils/configValidation.test.js @@ -411,7 +411,9 @@ describe('configValidation', () => { }); it('should accept valid xp.defaultActions array', () => { - expect(validateSingleValue('xp.defaultActions', [{ type: 'grantRole', roleId: '123' }])).toEqual([]); + expect( + validateSingleValue('xp.defaultActions', [{ type: 'grantRole', roleId: '123' }]), + ).toEqual([]); }); it('should reject defaultActions missing type', () => { @@ -420,7 +422,9 @@ describe('configValidation', () => { }); it('should accept valid xp.roleRewards object', () => { - expect(validateSingleValue('xp.roleRewards', { stackRoles: true, removeOnLevelDown: false })).toEqual([]); + expect( + validateSingleValue('xp.roleRewards', { stackRoles: true, removeOnLevelDown: false }), + ).toEqual([]); }); it('should reject non-boolean roleRewards.stackRoles', () => { diff --git a/tests/modules/actions/addReaction.test.js b/tests/modules/actions/addReaction.test.js index bef8a6dc1..317c67174 100644 --- a/tests/modules/actions/addReaction.test.js +++ b/tests/modules/actions/addReaction.test.js @@ -6,8 +6,8 @@ vi.mock('../../../src/logger.js', () => ({ error: vi.fn(), })); -import { handleAddReaction } from '../../../src/modules/actions/addReaction.js'; import { info, warn } from '../../../src/logger.js'; +import { handleAddReaction } from '../../../src/modules/actions/addReaction.js'; function makeContext({ reactFn } = {}) { const react = reactFn ?? vi.fn().mockResolvedValue(undefined); diff --git a/tests/modules/actions/announce.test.js b/tests/modules/actions/announce.test.js index 68987c1fc..de8fb1374 100644 --- a/tests/modules/actions/announce.test.js +++ b/tests/modules/actions/announce.test.js @@ -14,8 +14,8 @@ vi.mock('../../../src/utils/safeSend.js', () => ({ safeSend: vi.fn().mockResolvedValue(undefined), })); -import { handleAnnounce } from '../../../src/modules/actions/announce.js'; import { info, warn } from '../../../src/logger.js'; +import { handleAnnounce } from '../../../src/modules/actions/announce.js'; import { safeSend } from '../../../src/utils/safeSend.js'; import { renderTemplate } from '../../../src/utils/templateEngine.js'; diff --git a/tests/modules/actions/nickPrefix.test.js b/tests/modules/actions/nickPrefix.test.js index 6481480ca..60cf3410b 100644 --- a/tests/modules/actions/nickPrefix.test.js +++ b/tests/modules/actions/nickPrefix.test.js @@ -6,14 +6,10 @@ vi.mock('../../../src/logger.js', () => ({ error: vi.fn(), })); -import { handleNickPrefix, handleNickSuffix } from '../../../src/modules/actions/nickPrefix.js'; import { warn } from '../../../src/logger.js'; +import { handleNickPrefix, handleNickSuffix } from '../../../src/modules/actions/nickPrefix.js'; -function makeContext({ - displayName = 'TestUser', - hasPermission = true, - isOwner = false, -} = {}) { +function makeContext({ displayName = 'TestUser', hasPermission = true, isOwner = false } = {}) { const setNickname = vi.fn().mockResolvedValue(undefined); return { diff --git a/tests/modules/actions/sendDm.test.js b/tests/modules/actions/sendDm.test.js index 6e7d97d2f..ce787172b 100644 --- a/tests/modules/actions/sendDm.test.js +++ b/tests/modules/actions/sendDm.test.js @@ -11,6 +11,7 @@ vi.mock('../../../src/utils/templateEngine.js', () => ({ renderTemplate: vi.fn((tpl) => tpl), })); +import { debug, warn } from '../../../src/logger.js'; import { checkDmRateLimit, handleSendDm, @@ -18,7 +19,6 @@ import { resetDmLimits, sweepDmLimits, } from '../../../src/modules/actions/sendDm.js'; -import { debug, warn } from '../../../src/logger.js'; import { renderTemplate } from '../../../src/utils/templateEngine.js'; function makeContext({ sendFn } = {}) { @@ -175,3 +175,32 @@ describe('checkDmRateLimit', () => { vi.useRealTimers(); }); }); + +describe('sweepDmLimits', () => { + beforeEach(() => { + resetDmLimits(); + }); + + it('should evict stale entries and keep recent ones', () => { + vi.useFakeTimers(); + + // Record an entry that will become stale + recordDmSend('g1', 'u-stale'); + + // Advance past the rate window (60s) + vi.advanceTimersByTime(60_001); + + // Record a recent entry + recordDmSend('g1', 'u-recent'); + + // Sweep should evict the stale entry + sweepDmLimits(); + + // Stale entry evicted — should be allowed again + expect(checkDmRateLimit('g1', 'u-stale')).toBe(true); + // Recent entry still rate-limited + expect(checkDmRateLimit('g1', 'u-recent')).toBe(false); + + vi.useRealTimers(); + }); +}); diff --git a/tests/modules/actions/webhook.test.js b/tests/modules/actions/webhook.test.js index 8b48f020e..2038e0e29 100644 --- a/tests/modules/actions/webhook.test.js +++ b/tests/modules/actions/webhook.test.js @@ -6,8 +6,8 @@ vi.mock('../../../src/logger.js', () => ({ error: vi.fn(), })); -import { handleWebhook, validateWebhookUrl } from '../../../src/modules/actions/webhook.js'; import { info, warn } from '../../../src/logger.js'; +import { handleWebhook, validateWebhookUrl } from '../../../src/modules/actions/webhook.js'; function makeContext() { return { @@ -89,10 +89,7 @@ describe('handleWebhook', () => { }), ); - expect(info).toHaveBeenCalledWith( - 'webhook fired', - expect.objectContaining({ status: 200 }), - ); + expect(info).toHaveBeenCalledWith('webhook fired', expect.objectContaining({ status: 200 })); }); it('should skip on invalid URL', async () => { @@ -100,10 +97,7 @@ describe('handleWebhook', () => { globalThis.fetch = mockFetch; const ctx = makeContext(); - await handleWebhook( - { type: 'webhook', url: 'not-a-url', payload: '{}' }, - ctx, - ); + await handleWebhook({ type: 'webhook', url: 'not-a-url', payload: '{}' }, ctx); expect(mockFetch).not.toHaveBeenCalled(); expect(warn).toHaveBeenCalledWith( @@ -118,10 +112,7 @@ describe('handleWebhook', () => { globalThis.fetch = mockFetch; const ctx = makeContext(); - await handleWebhook( - { type: 'webhook', url: 'https://example.com/hook', payload: '{}' }, - ctx, - ); + await handleWebhook({ type: 'webhook', url: 'https://example.com/hook', payload: '{}' }, ctx); expect(warn).toHaveBeenCalledWith( 'webhook timed out (5s)', @@ -134,10 +125,7 @@ describe('handleWebhook', () => { globalThis.fetch = mockFetch; const ctx = makeContext(); - await handleWebhook( - { type: 'webhook', url: 'https://example.com/hook', payload: '{}' }, - ctx, - ); + await handleWebhook({ type: 'webhook', url: 'https://example.com/hook', payload: '{}' }, ctx); expect(warn).toHaveBeenCalledWith( 'webhook request failed', @@ -150,10 +138,7 @@ describe('handleWebhook', () => { globalThis.fetch = mockFetch; const ctx = makeContext(); - await handleWebhook( - { type: 'webhook', url: 'https://example.com/hook' }, - ctx, - ); + await handleWebhook({ type: 'webhook', url: 'https://example.com/hook' }, ctx); expect(mockFetch).toHaveBeenCalledWith( 'https://example.com/hook', diff --git a/tests/modules/actions/xpBonus.test.js b/tests/modules/actions/xpBonus.test.js index e0c19e582..748b9b4e3 100644 --- a/tests/modules/actions/xpBonus.test.js +++ b/tests/modules/actions/xpBonus.test.js @@ -11,8 +11,8 @@ vi.mock('../../../src/db.js', () => ({ getPool: () => ({ query: mockQuery }), })); -import { handleXpBonus, isXpBonusActive } from '../../../src/modules/actions/xpBonus.js'; import { warn } from '../../../src/logger.js'; +import { handleXpBonus, isXpBonusActive } from '../../../src/modules/actions/xpBonus.js'; function makeContext() { return { diff --git a/tests/modules/ai.test.js b/tests/modules/ai.test.js index 9b675cf28..b87073767 100644 --- a/tests/modules/ai.test.js +++ b/tests/modules/ai.test.js @@ -49,7 +49,8 @@ describe('ai module', () => { addToHistory('ch1', 'user', 'hello'); const history = await getHistoryAsync('ch1'); expect(history.length).toBe(1); - expect(history[0]).toEqual({ role: 'user', content: 'hello' }); + expect(history[0]).toMatchObject({ role: 'user', content: 'hello' }); + expect(history[0].timestamp).toEqual(expect.any(Number)); }); it('should hydrate DB history in-place when concurrent messages are added', async () => { @@ -74,8 +75,8 @@ describe('ai module', () => { resolveHydration({ rows: [ - { role: 'assistant', content: 'db reply' }, - { role: 'user', content: 'db message' }, + { role: 'assistant', content: 'db reply', created_at: '2026-04-01T10:00:01.000Z' }, + { role: 'user', content: 'db message', created_at: '2026-04-01T10:00:00.000Z' }, ], }); @@ -83,11 +84,22 @@ describe('ai module', () => { await asyncHistoryPromise; await vi.waitFor(() => { - expect(historyRef).toEqual([ - { role: 'user', content: 'db message' }, - { role: 'assistant', content: 'db reply' }, - { role: 'user', content: 'concurrent message' }, - ]); + expect(historyRef).toHaveLength(3); + expect(historyRef[0]).toMatchObject({ + role: 'user', + content: 'db message', + timestamp: Date.parse('2026-04-01T10:00:00.000Z'), + }); + expect(historyRef[1]).toMatchObject({ + role: 'assistant', + content: 'db reply', + timestamp: Date.parse('2026-04-01T10:00:01.000Z'), + }); + expect(historyRef[2]).toMatchObject({ + role: 'user', + content: 'concurrent message', + timestamp: expect.any(Number), + }); expect(getConversationHistory().get('race-channel')).toBe(historyRef); }); }); @@ -107,7 +119,7 @@ describe('ai module', () => { expect(history[0].content).toBe('from db'); expect(history[1].content).toBe('response'); expect(mockQuery).toHaveBeenCalledWith( - expect.stringContaining('SELECT role, content FROM conversations'), + expect.stringContaining('SELECT role, content, created_at FROM conversations'), ['ch-new', 20], ); }); diff --git a/web/src/components/dashboard/analytics-dashboard-sections.tsx b/web/src/components/dashboard/analytics-dashboard-sections.tsx index f34243ddd..723f806ce 100644 --- a/web/src/components/dashboard/analytics-dashboard-sections.tsx +++ b/web/src/components/dashboard/analytics-dashboard-sections.tsx @@ -117,7 +117,7 @@ function DeltaIcon({ delta }: { delta: number | null }) { export function KpiCardItem({ card, - compareMode, + compareMode: _compareMode, hasAnalytics, hasComparison, }: { diff --git a/web/src/components/dashboard/analytics-dashboard.tsx b/web/src/components/dashboard/analytics-dashboard.tsx index c7ef03051..726f062ae 100644 --- a/web/src/components/dashboard/analytics-dashboard.tsx +++ b/web/src/components/dashboard/analytics-dashboard.tsx @@ -34,7 +34,6 @@ import { ChannelFilterCard, CommandUsageCard, escapeCsvCell, - formatDeltaPercent, type KpiCard, KpiCardItem, KpiSkeleton, diff --git a/web/src/components/dashboard/config-editor.tsx b/web/src/components/dashboard/config-editor.tsx new file mode 100644 index 000000000..5b87c850a --- /dev/null +++ b/web/src/components/dashboard/config-editor.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { Save } from 'lucide-react'; +import { ConfigProvider, useConfigContext } from '@/components/dashboard/config-context'; +import { Button } from '@/components/ui/button'; +import { DiscardChangesButton } from './reset-defaults-button'; +import { SystemPromptEditor } from './system-prompt-editor'; + +export function ConfigEditor() { + return ( + + + + ); +} + +function ConfigEditorContent() { + const { + guildId, + draftConfig, + loading, + error, + saving, + hasChanges, + hasValidationErrors, + openDiffModal, + discardChanges, + fetchConfig, + updateDraftConfig, + } = useConfigContext(); + + if (!guildId) { + return
Select a server to manage its configuration.
; + } + + if (loading) { + return
Loading configuration...
; + } + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + if (!draftConfig) { + return null; + } + + return ( +
+
+
+

Bot Configuration

+

Manage core bot settings in one place.

+
+
+ + +
+
+ +
+

AI Chat

+ + updateDraftConfig((prev) => ({ + ...prev, + ai: { ...prev.ai, systemPrompt: value }, + })) + } + /> +
+ + {/* TODO: Integrate DiscordMarkdownEditor for welcome message template editing */} +
+

Welcome Messages

+

+ Configure welcome message templates for new server members. +

+
+
+ ); +} diff --git a/web/src/components/dashboard/config-sections/ReputationSection.tsx b/web/src/components/dashboard/config-sections/ReputationSection.tsx index 238f4821b..530cd54dd 100644 --- a/web/src/components/dashboard/config-sections/ReputationSection.tsx +++ b/web/src/components/dashboard/config-sections/ReputationSection.tsx @@ -10,7 +10,9 @@ interface ReputationSectionProps { draftConfig: GuildConfig; saving: boolean; onEnabledChange: (enabled: boolean) => void; + /** @deprecated Prefer updateDraftConfig for consistent update patterns. */ onFieldChange: (field: string, value: unknown) => void; + updateDraftConfig: (updater: (prev: GuildConfig) => GuildConfig) => void; } /** Shared input styling for text inputs. */ @@ -22,16 +24,17 @@ const DEFAULT_LEVEL_THRESHOLDS = [100, 300, 600, 1000, 1500, 2500, 4000, 6000, 8 /** * Reputation / XP configuration section. * - * Provides controls for XP settings, cooldowns, level thresholds, and announcements. + * Provides controls for XP settings, cooldowns, and level thresholds. */ export function ReputationSection({ draftConfig, saving, onEnabledChange, - onFieldChange, + onFieldChange: _onFieldChange, + updateDraftConfig, }: ReputationSectionProps) { const xpRange = draftConfig.reputation?.xpPerMessage ?? [5, 15]; - const levelThresholds = draftConfig.reputation?.levelThresholds ?? DEFAULT_LEVEL_THRESHOLDS; + const levelThresholds = draftConfig.xp?.levelThresholds ?? DEFAULT_LEVEL_THRESHOLDS; // Local state for level thresholds raw input (parsed on blur) const thresholdsDisplay = levelThresholds.join(', '); @@ -65,7 +68,10 @@ export function ReputationSection({ const num = parseNumberInput(e.target.value, 1, 100); if (num !== undefined) { const newMax = num > (xpRange[1] ?? 15) ? num : (xpRange[1] ?? 15); - onFieldChange('xpPerMessage', [num, newMax]); + updateDraftConfig((prev) => ({ + ...prev, + reputation: { ...prev.reputation, xpPerMessage: [num, newMax] }, + })); } }} disabled={saving} @@ -84,7 +90,10 @@ export function ReputationSection({ const num = parseNumberInput(e.target.value, 1, 100); if (num !== undefined) { const newMin = num < (xpRange[0] ?? 5) ? num : (xpRange[0] ?? 5); - onFieldChange('xpPerMessage', [newMin, num]); + updateDraftConfig((prev) => ({ + ...prev, + reputation: { ...prev.reputation, xpPerMessage: [newMin, num] }, + })); } }} disabled={saving} @@ -100,24 +109,17 @@ export function ReputationSection({ value={draftConfig.reputation?.xpCooldownSeconds ?? 60} onChange={(e) => { const num = parseNumberInput(e.target.value, 0); - if (num !== undefined) onFieldChange('xpCooldownSeconds', num); + if (num !== undefined) { + updateDraftConfig((prev) => ({ + ...prev, + reputation: { ...prev.reputation, xpCooldownSeconds: num }, + })); + } }} disabled={saving} className={inputClasses} /> -