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}
/>
-