From 75bdf21a092283b70bbb2dac7a42dc4e8b6b3dfb Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 4 May 2026 00:22:03 +0900 Subject: [PATCH] Delete dashboard duplicates on create event Event-driven cleanup removes stale dashboard messages as soon as Discord emits messageCreate, while preserving the tracked status message. --- .../discord-dashboard-create-cleanup.test.ts | 95 +++++++++++++++++++ .../discord-dashboard-create-cleanup.ts | 55 +++++++++++ src/channels/discord.test.ts | 1 + src/channels/discord.ts | 21 +++- src/dashboard-message-cleanup.ts | 2 +- 5 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 src/channels/discord-dashboard-create-cleanup.test.ts create mode 100644 src/channels/discord-dashboard-create-cleanup.ts diff --git a/src/channels/discord-dashboard-create-cleanup.test.ts b/src/channels/discord-dashboard-create-cleanup.test.ts new file mode 100644 index 0000000..5281f69 --- /dev/null +++ b/src/channels/discord-dashboard-create-cleanup.test.ts @@ -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(); + }); +}); diff --git a/src/channels/discord-dashboard-create-cleanup.ts b/src/channels/discord-dashboard-create-cleanup.ts new file mode 100644 index 0000000..9afd05b --- /dev/null +++ b/src/channels/discord-dashboard-create-cleanup.ts @@ -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 { + 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', + ); + } +} diff --git a/src/channels/discord.test.ts b/src/channels/discord.test.ts index 4f7ecdb..3f8cf4e 100644 --- a/src/channels/discord.test.ts +++ b/src/channels/discord.test.ts @@ -29,6 +29,7 @@ vi.mock('../config.js', () => ({ TRIGGER_PATTERN: /^@Andy\b/i, DATA_DIR: '/tmp/ejclaw-test-data', CACHE_DIR: '/tmp/ejclaw-test-cache', + STATUS_CHANNEL_ID: 'status-channel', })); // Mock logger diff --git a/src/channels/discord.ts b/src/channels/discord.ts index e5d42c9..f31c985 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -22,6 +22,11 @@ import type { SendMessageOptions, SendMessageResult, } 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 { prepareDiscordOutbound } from './discord-outbound.js'; @@ -193,6 +198,7 @@ export class DiscordChannel implements Channel { private agentTypeFilter?: AgentType; private receivesInbound: boolean; private ownsDiscordJids: boolean; + private dashboardTrackedSendGraceUntil = 0; constructor( botToken: string, @@ -253,9 +259,16 @@ export class DiscordChannel implements Channel { private async handleMessageCreate(message: Message): Promise { const channelId = message.channelId; const chatJid = `dc:${channelId}`; - if (!this.receivesInbound) return; 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; let content = message.content; @@ -635,6 +648,10 @@ export class DiscordChannel implements Channel { } try { 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); if (!channel || !('send' in channel)) { throw new Error(`Discord channel not found or not text-based: ${jid}`); diff --git a/src/dashboard-message-cleanup.ts b/src/dashboard-message-cleanup.ts index 46dc11a..3ac4558 100644 --- a/src/dashboard-message-cleanup.ts +++ b/src/dashboard-message-cleanup.ts @@ -1,7 +1,7 @@ import { logger } from './logger.js'; import type { Channel } from './types.js'; -const DASHBOARD_STATUS_MESSAGE_MARKER = '🤖 *모델 구성*'; +export const DASHBOARD_STATUS_MESSAGE_MARKER = '🤖 *모델 구성*'; const DASHBOARD_DUPLICATE_CLEANUP_LIMIT = 100; function findDashboardCleanupChannel(opts: {