Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}-
Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/modules/actions/buildPayload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
9 changes: 5 additions & 4 deletions src/modules/actions/xpBonus.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion src/modules/ai.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand All @@ -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.
Expand Down Expand Up @@ -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(),
});
Comment on lines 374 to 378
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initConversationHistory now assigns timestamp from row.created_at, but the hydration query’s outer SELECT only returns channel_id, role, content (no created_at). As a result row.created_at will always be undefined here and timestamps will fall back to Date.now(), losing DB ordering/time fidelity. Include created_at in the outer SELECT (and ensure it’s available on row).

Copilot uses AI. Check for mistakes.
}

Expand Down
2 changes: 1 addition & 1 deletion src/modules/levelUpActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/modules/triage-parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 10 additions & 2 deletions src/modules/triage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/modules/welcomeOnboarding.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 6 additions & 2 deletions tests/api/utils/configValidation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down
2 changes: 1 addition & 1 deletion tests/modules/actions/addReaction.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion tests/modules/actions/announce.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
8 changes: 2 additions & 6 deletions tests/modules/actions/nickPrefix.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
31 changes: 30 additions & 1 deletion tests/modules/actions/sendDm.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ vi.mock('../../../src/utils/templateEngine.js', () => ({
renderTemplate: vi.fn((tpl) => tpl),
}));

import { debug, warn } from '../../../src/logger.js';
import {
checkDmRateLimit,
handleSendDm,
recordDmSend,
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 } = {}) {
Expand Down Expand Up @@ -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();
});
});
27 changes: 6 additions & 21 deletions tests/modules/actions/webhook.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -89,21 +89,15 @@ 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 () => {
const mockFetch = vi.fn();
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(
Expand All @@ -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)',
Expand All @@ -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',
Expand All @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion tests/modules/actions/xpBonus.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
30 changes: 21 additions & 9 deletions tests/modules/ai.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -74,20 +75,31 @@ 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' },
],
});

await hydrationPromise;
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);
});
});
Expand All @@ -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],
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ function DeltaIcon({ delta }: { delta: number | null }) {

export function KpiCardItem({
card,
compareMode,
compareMode: _compareMode,
hasAnalytics,
hasComparison,
}: {
Expand Down
1 change: 0 additions & 1 deletion web/src/components/dashboard/analytics-dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ import {
ChannelFilterCard,
CommandUsageCard,
escapeCsvCell,
formatDeltaPercent,
type KpiCard,
KpiCardItem,
KpiSkeleton,
Expand Down
Loading
Loading