From 2be267c51e655293d836fdf488896d2f5156131e Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sun, 3 May 2026 23:56:07 +0900 Subject: [PATCH] Clean duplicate dashboard status messages Clean duplicate Status dashboard messages by marker, preserve the tracked message, and avoid full-channel purge on startup. --- src/channels/discord-message-cleanup.test.ts | 90 ++++++++++++++++++++ src/channels/discord-message-cleanup.ts | 81 ++++++++++++++++++ src/channels/discord.ts | 19 ++++- src/dashboard-message-cleanup.test.ts | 88 +++++++++++++++++++ src/dashboard-message-cleanup.ts | 67 +++++++++++++++ src/types.ts | 10 +++ src/unified-dashboard.test.ts | 12 +-- src/unified-dashboard.ts | 23 +++-- 8 files changed, 367 insertions(+), 23 deletions(-) create mode 100644 src/channels/discord-message-cleanup.test.ts create mode 100644 src/channels/discord-message-cleanup.ts create mode 100644 src/dashboard-message-cleanup.test.ts create mode 100644 src/dashboard-message-cleanup.ts diff --git a/src/channels/discord-message-cleanup.test.ts b/src/channels/discord-message-cleanup.test.ts new file mode 100644 index 0000000..638d896 --- /dev/null +++ b/src/channels/discord-message-cleanup.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { deleteRecentDiscordMessagesByContent } from './discord-message-cleanup.js'; + +class MockMessageCollection extends Map { + filter(predicate: (message: any, id: string) => boolean) { + const filtered = new MockMessageCollection(); + for (const [id, message] of this.entries()) { + if (predicate(message, id)) filtered.set(id, message); + } + return filtered; + } +} + +describe('deleteRecentDiscordMessagesByContent', () => { + it('deletes duplicate own dashboard messages while keeping the tracked one', async () => { + const keepDelete = vi.fn(); + const duplicateDelete = vi.fn(); + const otherBotDelete = vi.fn(); + const normalDelete = vi.fn(); + const messages = new MockMessageCollection([ + [ + 'tracked', + { + id: 'tracked', + author: { id: '999888777' }, + content: '🤖 *모델 구성*\ntracked', + createdTimestamp: Date.now(), + delete: keepDelete, + }, + ], + [ + 'duplicate', + { + id: 'duplicate', + author: { id: '999888777' }, + content: '🤖 *모델 구성*\nduplicate', + createdTimestamp: Date.now(), + delete: duplicateDelete, + }, + ], + [ + 'other-bot', + { + id: 'other-bot', + author: { id: 'other-bot' }, + content: '🤖 *모델 구성*\nother bot', + createdTimestamp: Date.now(), + delete: otherBotDelete, + }, + ], + [ + 'normal', + { + id: 'normal', + author: { id: '999888777' }, + content: 'normal tracked message', + createdTimestamp: Date.now(), + delete: normalDelete, + }, + ], + ]); + const client = { + user: { id: '999888777' }, + channels: { + fetch: vi.fn().mockResolvedValue({ + messages: { + fetch: vi.fn().mockResolvedValue(messages), + }, + }), + }, + }; + + const deleted = await deleteRecentDiscordMessagesByContent({ + client: client as any, + channelName: 'discord', + jid: 'dc:1234567890123456', + options: { + contentIncludes: '🤖 *모델 구성*', + exceptMessageId: 'tracked', + }, + }); + + expect(deleted).toBe(1); + expect(duplicateDelete).toHaveBeenCalledTimes(1); + expect(keepDelete).not.toHaveBeenCalled(); + expect(otherBotDelete).not.toHaveBeenCalled(); + expect(normalDelete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/channels/discord-message-cleanup.ts b/src/channels/discord-message-cleanup.ts new file mode 100644 index 0000000..52599f1 --- /dev/null +++ b/src/channels/discord-message-cleanup.ts @@ -0,0 +1,81 @@ +import type { Client, TextChannel } from 'discord.js'; + +import { logger } from '../logger.js'; +import type { DeleteRecentMessagesByContentOptions } from '../types.js'; + +export async function deleteRecentDiscordMessagesByContent(args: { + client: Client | null; + channelName: string; + jid: string; + options: DeleteRecentMessagesByContentOptions; +}): Promise { + if (!args.client) return 0; + + const contentIncludes = args.options.contentIncludes.trim(); + if (!contentIncludes) return 0; + + let deleted = 0; + try { + const channelId = args.jid.replace(/^dc:/, ''); + const channel = await args.client.channels.fetch(channelId); + if (!channel || !('messages' in channel)) return 0; + + const tc = channel as TextChannel; + const messages = await tc.messages.fetch({ + limit: Math.max(1, Math.min(args.options.limit ?? 100, 100)), + }); + const candidates = messages.filter( + (message) => + message.author.id === args.client?.user?.id && + message.id !== args.options.exceptMessageId && + message.content.includes(contentIncludes), + ); + if (candidates.size === 0) return 0; + + const twoWeeksAgo = Date.now() - 14 * 24 * 60 * 60 * 1000; + const recent = candidates.filter( + (message) => message.createdTimestamp > twoWeeksAgo, + ); + const old = candidates.filter( + (message) => message.createdTimestamp <= twoWeeksAgo, + ); + + if (recent.size >= 2 && 'bulkDelete' in channel) { + await tc.bulkDelete(recent); + deleted += recent.size; + } else { + for (const [, message] of recent) { + await message.delete(); + deleted += 1; + } + } + + for (const [, message] of old) { + await message.delete(); + deleted += 1; + } + + logger.info( + { + jid: args.jid, + deleted, + exceptMessageId: args.options.exceptMessageId ?? null, + channelName: args.channelName, + }, + 'Deleted duplicate Discord messages by content marker', + ); + } catch (err) { + logger.warn( + { + jid: args.jid, + err, + deleted, + exceptMessageId: args.options.exceptMessageId ?? null, + channelName: args.channelName, + }, + 'Failed to delete duplicate Discord messages by content marker', + ); + } + + return deleted; +} diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 16a32c3..e5d42c9 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -17,7 +17,12 @@ import { logger } from '../logger.js'; import { validateOutboundAttachments } from '../outbound-attachments.js'; import { formatOutbound } from '../router.js'; import { hasReviewerLease } from '../service-routing.js'; -import type { SendMessageOptions, SendMessageResult } from '../types.js'; +import type { + DeleteRecentMessagesByContentOptions, + SendMessageOptions, + SendMessageResult, +} from '../types.js'; +import { deleteRecentDiscordMessagesByContent } from './discord-message-cleanup.js'; import { prepareDiscordOutbound } from './discord-outbound.js'; const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments'); @@ -760,6 +765,18 @@ export class DiscordChannel implements Channel { return deleted; } + async deleteRecentMessagesByContent( + jid: string, + options: DeleteRecentMessagesByContentOptions, + ): Promise { + return deleteRecentDiscordMessagesByContent({ + client: this.client, + channelName: this.name, + jid, + options, + }); + } + async editMessage( jid: string, messageId: string, diff --git a/src/dashboard-message-cleanup.test.ts b/src/dashboard-message-cleanup.test.ts new file mode 100644 index 0000000..4a29095 --- /dev/null +++ b/src/dashboard-message-cleanup.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + cleanupDashboardDuplicateMessages, + purgeDashboardMessages, +} from './dashboard-message-cleanup.js'; + +function makeChannel(deleteRecentMessagesByContent: any) { + return { + name: 'discord', + connect: vi.fn(), + sendMessage: vi.fn(), + isConnected: () => true, + ownsJid: () => true, + disconnect: vi.fn(), + deleteRecentMessagesByContent, + }; +} + +describe('cleanupDashboardDuplicateMessages', () => { + it('deletes recent dashboard messages except the tracked status message', async () => { + const deleteRecentMessagesByContent = vi.fn(async () => 3); + const deleted = await cleanupDashboardDuplicateMessages( + { + statusChannelId: 'status-channel', + channels: [makeChannel(deleteRecentMessagesByContent)], + }, + 'tracked-message', + ); + + expect(deleted).toBe(3); + expect(deleteRecentMessagesByContent).toHaveBeenCalledWith( + 'dc:status-channel', + { + contentIncludes: '🤖 *모델 구성*', + exceptMessageId: 'tracked-message', + limit: 100, + }, + ); + }); + + it('skips cleanup when there is no tracked status message yet', async () => { + const deleteRecentMessagesByContent = vi.fn(async () => 3); + const deleted = await cleanupDashboardDuplicateMessages( + { + statusChannelId: 'status-channel', + channels: [makeChannel(deleteRecentMessagesByContent)], + }, + null, + ); + + expect(deleted).toBe(0); + expect(deleteRecentMessagesByContent).not.toHaveBeenCalled(); + }); +}); + +describe('purgeDashboardMessages', () => { + it('deletes dashboard marker messages without purging the whole channel', async () => { + const deleteRecentMessagesByContent = vi.fn(async () => 2); + const deleted = await purgeDashboardMessages({ + statusChannelId: 'status-channel', + channels: [makeChannel(deleteRecentMessagesByContent)], + }); + + expect(deleted).toBe(2); + expect(deleteRecentMessagesByContent).toHaveBeenCalledWith( + 'dc:status-channel', + { + contentIncludes: '🤖 *모델 구성*', + limit: 100, + }, + ); + }); + + it('continues deleting dashboard batches until the last batch is partial', async () => { + const deleteRecentMessagesByContent = vi + .fn() + .mockResolvedValueOnce(100) + .mockResolvedValueOnce(7); + const deleted = await purgeDashboardMessages({ + statusChannelId: 'status-channel', + channels: [makeChannel(deleteRecentMessagesByContent)], + }); + + expect(deleted).toBe(107); + expect(deleteRecentMessagesByContent).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/dashboard-message-cleanup.ts b/src/dashboard-message-cleanup.ts new file mode 100644 index 0000000..46dc11a --- /dev/null +++ b/src/dashboard-message-cleanup.ts @@ -0,0 +1,67 @@ +import { logger } from './logger.js'; +import type { Channel } from './types.js'; + +const DASHBOARD_STATUS_MESSAGE_MARKER = '🤖 *모델 구성*'; +const DASHBOARD_DUPLICATE_CLEANUP_LIMIT = 100; + +function findDashboardCleanupChannel(opts: { + channels: Channel[]; + statusChannelId: string; +}): Channel | undefined { + if (!opts.statusChannelId) return undefined; + return opts.channels.find( + (item) => + item.name.startsWith('discord') && + item.isConnected() && + item.deleteRecentMessagesByContent, + ); +} + +export async function purgeDashboardMessages(opts: { + channels: Channel[]; + statusChannelId: string; +}): Promise { + const channel = findDashboardCleanupChannel(opts); + if (!channel?.deleteRecentMessagesByContent) return 0; + + let total = 0; + for (let i = 0; i < 10; i += 1) { + const deleted = await channel.deleteRecentMessagesByContent( + `dc:${opts.statusChannelId}`, + { + contentIncludes: DASHBOARD_STATUS_MESSAGE_MARKER, + limit: DASHBOARD_DUPLICATE_CLEANUP_LIMIT, + }, + ); + total += deleted; + if (deleted < DASHBOARD_DUPLICATE_CLEANUP_LIMIT) break; + } + return total; +} + +export async function cleanupDashboardDuplicateMessages( + opts: { channels: Channel[]; statusChannelId: string }, + keepMessageId: string | null, +): Promise { + if (!opts.statusChannelId || !keepMessageId) return 0; + + const channel = findDashboardCleanupChannel(opts); + if (!channel?.deleteRecentMessagesByContent) return 0; + + try { + return await channel.deleteRecentMessagesByContent( + `dc:${opts.statusChannelId}`, + { + contentIncludes: DASHBOARD_STATUS_MESSAGE_MARKER, + exceptMessageId: keepMessageId, + limit: DASHBOARD_DUPLICATE_CLEANUP_LIMIT, + }, + ); + } catch (err) { + logger.warn( + { err, keepMessageId }, + 'Dashboard duplicate message cleanup failed', + ); + return 0; + } +} diff --git a/src/types.ts b/src/types.ts index ce35bfe..20b1c19 100644 --- a/src/types.ts +++ b/src/types.ts @@ -49,6 +49,12 @@ export interface SendMessageResult { visible: boolean; } +export interface DeleteRecentMessagesByContentOptions { + contentIncludes: string; + exceptMessageId?: string | null; + limit?: number; +} + export type PairedRoomRole = 'owner' | 'reviewer' | 'arbiter'; export type PairedTaskStatus = @@ -273,6 +279,10 @@ export interface Channel { // Optional: edit/delete messages (used by status dashboard and tracked progress cleanup). editMessage?(jid: string, messageId: string, text: string): Promise; deleteMessage?(jid: string, messageId: string): Promise; + deleteRecentMessagesByContent?( + jid: string, + options: DeleteRecentMessagesByContentOptions, + ): Promise; sendAndTrack?(jid: string, text: string): Promise; // Optional: sync group/chat names from the platform. syncGroups?(force: boolean): Promise; diff --git a/src/unified-dashboard.test.ts b/src/unified-dashboard.test.ts index fbc7f0d..9a30881 100644 --- a/src/unified-dashboard.test.ts +++ b/src/unified-dashboard.test.ts @@ -63,22 +63,16 @@ describe('formatStatusHeader', () => { }); describe('shouldPurgeDashboardChannelOnStart', () => { - it('keeps the existing status message when a stored message id exists', () => { + it('purges on startup when explicitly requested, even with a stored message id', () => { expect( shouldPurgeDashboardChannelOnStart({ purgeOnStart: true, storedMessageId: 'status-message-1', }), - ).toBe(false); + ).toBe(true); }); - it('purges only when explicitly requested and no stored message id exists', () => { - expect( - shouldPurgeDashboardChannelOnStart({ - purgeOnStart: true, - storedMessageId: null, - }), - ).toBe(true); + it('does not purge when startup purge is disabled', () => { expect( shouldPurgeDashboardChannelOnStart({ purgeOnStart: false, diff --git a/src/unified-dashboard.ts b/src/unified-dashboard.ts index 9aac39f..ffe726e 100644 --- a/src/unified-dashboard.ts +++ b/src/unified-dashboard.ts @@ -41,6 +41,10 @@ import { type DashboardRoomLine, renderCategorizedRoomSections, } from './dashboard-render.js'; +import { + cleanupDashboardDuplicateMessages, + purgeDashboardMessages, +} from './dashboard-message-cleanup.js'; import { buildClaudeUsageRows, extractCodexUsageRows, @@ -157,25 +161,14 @@ function findDiscordChannel(channels: Channel[]): Channel | undefined { export async function purgeDashboardChannel( opts: Pick, ): Promise { - if (!opts.statusChannelId) return; - - const statusJid = `dc:${opts.statusChannelId}`; - const channel = opts.channels.find( - (item) => - item.name.startsWith('discord') && - item.isConnected() && - item.purgeChannel, - ); - if (channel?.purgeChannel) { - await channel.purgeChannel(statusJid); - } + await purgeDashboardMessages(opts); } export function shouldPurgeDashboardChannelOnStart(args: { purgeOnStart?: boolean; storedMessageId: string | null; }): boolean { - return args.purgeOnStart === true && !args.storedMessageId; + return args.purgeOnStart === true; } async function refreshChannelMeta( @@ -729,6 +722,7 @@ export async function startUnifiedDashboard( }) ) { await purgeDashboardChannel(opts); + statusMessageId = null; } if (isRenderer) { @@ -787,6 +781,9 @@ export async function startUnifiedDashboard( writeDashboardStatusMessageId(opts.statusChannelId, id); } } + if (statusMessageId) { + await cleanupDashboardDuplicateMessages(opts, statusMessageId); + } if (!dashboardUpdateLogged) { logger.info( { messageId: statusMessageId, contentLength: content.length },