fix: delete dashboard duplicates on create

This commit is contained in:
ejclaw
2026-05-04 00:19:03 +09:00
parent 9315dcb90f
commit f5c17d031c
5 changed files with 171 additions and 3 deletions

View File

@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../config.js', () => ({
STATUS_CHANNEL_ID: 'status-channel',
}));
vi.mock('../logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
},
}));
const readDashboardStatusMessageIdMock = vi.hoisted(() =>
vi.fn(() => 'dashboard-current'),
);
vi.mock('../status-dashboard.js', () => ({
readDashboardStatusMessageId: readDashboardStatusMessageIdMock,
}));
import {
deleteOwnDashboardDuplicateOnCreate,
isDashboardTrackedSend,
} from './discord-dashboard-create-cleanup.js';
function makeDashboardMessage(overrides: {
id?: string;
channelId?: string;
content?: string;
}) {
return {
id: overrides.id ?? 'dashboard-stale',
channelId: overrides.channelId ?? 'status-channel',
content: overrides.content ?? '🤖 *모델 구성*\nMemory 3.3/91.4GB',
delete: vi.fn().mockResolvedValue(undefined),
} as any;
}
describe('discord dashboard create cleanup', () => {
beforeEach(() => {
vi.clearAllMocks();
readDashboardStatusMessageIdMock.mockReturnValue('dashboard-current');
});
it('detects tracked dashboard sends', () => {
expect(
isDashboardTrackedSend(
'dc:status-channel',
'🤖 *모델 구성*\nMemory 7.5/251.7GB',
),
).toBe(true);
expect(isDashboardTrackedSend('dc:other', '🤖 *모델 구성*')).toBe(false);
expect(isDashboardTrackedSend('dc:status-channel', 'normal')).toBe(false);
});
it('deletes stale own dashboard messages on create event', async () => {
const msg = makeDashboardMessage({ id: 'dashboard-stale' });
await deleteOwnDashboardDuplicateOnCreate({
message: msg,
channelName: 'discord',
graceUntil: 0,
now: 100,
});
expect(msg.delete).toHaveBeenCalledTimes(1);
});
it('keeps the tracked dashboard message', async () => {
const msg = makeDashboardMessage({ id: 'dashboard-current' });
await deleteOwnDashboardDuplicateOnCreate({
message: msg,
channelName: 'discord',
graceUntil: 0,
now: 100,
});
expect(msg.delete).not.toHaveBeenCalled();
});
it('keeps dashboard messages during tracked send grace period', async () => {
const msg = makeDashboardMessage({ id: 'dashboard-new' });
await deleteOwnDashboardDuplicateOnCreate({
message: msg,
channelName: 'discord',
graceUntil: 200,
now: 100,
});
expect(msg.delete).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,55 @@
import type { Message } from 'discord.js';
import { STATUS_CHANNEL_ID } from '../config.js';
import { DASHBOARD_STATUS_MESSAGE_MARKER } from '../dashboard-message-cleanup.js';
import { logger } from '../logger.js';
import { readDashboardStatusMessageId } from '../status-dashboard.js';
export const DASHBOARD_TRACKED_SEND_GRACE_MS = 5000;
export function isDashboardTrackedSend(jid: string, text: string): boolean {
return (
Boolean(STATUS_CHANNEL_ID) &&
jid.replace(/^dc:/, '') === STATUS_CHANNEL_ID &&
text.includes(DASHBOARD_STATUS_MESSAGE_MARKER)
);
}
export async function deleteOwnDashboardDuplicateOnCreate(args: {
message: Message;
channelName: string;
graceUntil: number;
now?: number;
}): Promise<void> {
const { message } = args;
if (!STATUS_CHANNEL_ID || message.channelId !== STATUS_CHANNEL_ID) return;
if (!message.content.includes(DASHBOARD_STATUS_MESSAGE_MARKER)) return;
const keepMessageId = readDashboardStatusMessageId(STATUS_CHANNEL_ID);
if (!keepMessageId || message.id === keepMessageId) return;
if ((args.now ?? Date.now()) < args.graceUntil) return;
try {
await message.delete();
logger.info(
{
jid: `dc:${message.channelId}`,
messageId: message.id,
keepMessageId,
channelName: args.channelName,
},
'Deleted duplicate dashboard message on Discord create event',
);
} catch (err) {
logger.debug(
{
jid: `dc:${message.channelId}`,
messageId: message.id,
keepMessageId,
channelName: args.channelName,
err,
},
'Failed to delete duplicate dashboard message on Discord create event',
);
}
}

View File

@@ -29,6 +29,7 @@ vi.mock('../config.js', () => ({
TRIGGER_PATTERN: /^@Andy\b/i, TRIGGER_PATTERN: /^@Andy\b/i,
DATA_DIR: '/tmp/ejclaw-test-data', DATA_DIR: '/tmp/ejclaw-test-data',
CACHE_DIR: '/tmp/ejclaw-test-cache', CACHE_DIR: '/tmp/ejclaw-test-cache',
STATUS_CHANNEL_ID: 'status-channel',
})); }));
// Mock logger // Mock logger

View File

@@ -22,6 +22,11 @@ import type {
SendMessageOptions, SendMessageOptions,
SendMessageResult, SendMessageResult,
} from '../types.js'; } from '../types.js';
import {
DASHBOARD_TRACKED_SEND_GRACE_MS,
deleteOwnDashboardDuplicateOnCreate,
isDashboardTrackedSend,
} from './discord-dashboard-create-cleanup.js';
import { deleteRecentDiscordMessagesByContent } from './discord-message-cleanup.js'; import { deleteRecentDiscordMessagesByContent } from './discord-message-cleanup.js';
import { prepareDiscordOutbound } from './discord-outbound.js'; import { prepareDiscordOutbound } from './discord-outbound.js';
@@ -193,6 +198,7 @@ export class DiscordChannel implements Channel {
private agentTypeFilter?: AgentType; private agentTypeFilter?: AgentType;
private receivesInbound: boolean; private receivesInbound: boolean;
private ownsDiscordJids: boolean; private ownsDiscordJids: boolean;
private dashboardTrackedSendGraceUntil = 0;
constructor( constructor(
botToken: string, botToken: string,
@@ -253,9 +259,16 @@ export class DiscordChannel implements Channel {
private async handleMessageCreate(message: Message): Promise<void> { private async handleMessageCreate(message: Message): Promise<void> {
const channelId = message.channelId; const channelId = message.channelId;
const chatJid = `dc:${channelId}`; const chatJid = `dc:${channelId}`;
if (!this.receivesInbound) return;
const isOwnBotMessage = message.author.id === this.client?.user?.id; const isOwnBotMessage = message.author.id === this.client?.user?.id;
if (isOwnBotMessage) return; if (isOwnBotMessage) {
await deleteOwnDashboardDuplicateOnCreate({
message,
channelName: this.name,
graceUntil: this.dashboardTrackedSendGraceUntil,
});
return;
}
if (!this.receivesInbound) return;
if (message.author.bot && !hasReviewerLease(chatJid)) return; if (message.author.bot && !hasReviewerLease(chatJid)) return;
let content = message.content; let content = message.content;
@@ -635,6 +648,10 @@ export class DiscordChannel implements Channel {
} }
try { try {
const channelId = jid.replace(/^dc:/, ''); const channelId = jid.replace(/^dc:/, '');
if (isDashboardTrackedSend(jid, text)) {
this.dashboardTrackedSendGraceUntil =
Date.now() + DASHBOARD_TRACKED_SEND_GRACE_MS;
}
const channel = await this.client.channels.fetch(channelId); const channel = await this.client.channels.fetch(channelId);
if (!channel || !('send' in channel)) { if (!channel || !('send' in channel)) {
throw new Error(`Discord channel not found or not text-based: ${jid}`); throw new Error(`Discord channel not found or not text-based: ${jid}`);

View File

@@ -1,7 +1,7 @@
import { logger } from './logger.js'; import { logger } from './logger.js';
import type { Channel } from './types.js'; import type { Channel } from './types.js';
const DASHBOARD_STATUS_MESSAGE_MARKER = '🤖 *모델 구성*'; export const DASHBOARD_STATUS_MESSAGE_MARKER = '🤖 *모델 구성*';
const DASHBOARD_DUPLICATE_CLEANUP_LIMIT = 100; const DASHBOARD_DUPLICATE_CLEANUP_LIMIT = 100;
function findDashboardCleanupChannel(opts: { function findDashboardCleanupChannel(opts: {