Clean duplicate dashboard status messages

Clean duplicate Status dashboard messages by marker, preserve the tracked message, and avoid full-channel purge on startup.
This commit is contained in:
Eyejoker
2026-05-03 23:56:07 +09:00
committed by GitHub
parent a4b7aef3bd
commit 2be267c51e
8 changed files with 367 additions and 23 deletions

View File

@@ -0,0 +1,90 @@
import { describe, expect, it, vi } from 'vitest';
import { deleteRecentDiscordMessagesByContent } from './discord-message-cleanup.js';
class MockMessageCollection extends Map<string, any> {
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();
});
});

View File

@@ -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<number> {
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;
}

View File

@@ -17,7 +17,12 @@ import { logger } from '../logger.js';
import { validateOutboundAttachments } from '../outbound-attachments.js'; import { validateOutboundAttachments } from '../outbound-attachments.js';
import { formatOutbound } from '../router.js'; import { formatOutbound } from '../router.js';
import { hasReviewerLease } from '../service-routing.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'; import { prepareDiscordOutbound } from './discord-outbound.js';
const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments'); const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments');
@@ -760,6 +765,18 @@ export class DiscordChannel implements Channel {
return deleted; return deleted;
} }
async deleteRecentMessagesByContent(
jid: string,
options: DeleteRecentMessagesByContentOptions,
): Promise<number> {
return deleteRecentDiscordMessagesByContent({
client: this.client,
channelName: this.name,
jid,
options,
});
}
async editMessage( async editMessage(
jid: string, jid: string,
messageId: string, messageId: string,

View File

@@ -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);
});
});

View File

@@ -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<number> {
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<number> {
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;
}
}

View File

@@ -49,6 +49,12 @@ export interface SendMessageResult {
visible: boolean; visible: boolean;
} }
export interface DeleteRecentMessagesByContentOptions {
contentIncludes: string;
exceptMessageId?: string | null;
limit?: number;
}
export type PairedRoomRole = 'owner' | 'reviewer' | 'arbiter'; export type PairedRoomRole = 'owner' | 'reviewer' | 'arbiter';
export type PairedTaskStatus = export type PairedTaskStatus =
@@ -273,6 +279,10 @@ export interface Channel {
// Optional: edit/delete messages (used by status dashboard and tracked progress cleanup). // Optional: edit/delete messages (used by status dashboard and tracked progress cleanup).
editMessage?(jid: string, messageId: string, text: string): Promise<void>; editMessage?(jid: string, messageId: string, text: string): Promise<void>;
deleteMessage?(jid: string, messageId: string): Promise<void>; deleteMessage?(jid: string, messageId: string): Promise<void>;
deleteRecentMessagesByContent?(
jid: string,
options: DeleteRecentMessagesByContentOptions,
): Promise<number>;
sendAndTrack?(jid: string, text: string): Promise<string | null>; sendAndTrack?(jid: string, text: string): Promise<string | null>;
// Optional: sync group/chat names from the platform. // Optional: sync group/chat names from the platform.
syncGroups?(force: boolean): Promise<void>; syncGroups?(force: boolean): Promise<void>;

View File

@@ -63,22 +63,16 @@ describe('formatStatusHeader', () => {
}); });
describe('shouldPurgeDashboardChannelOnStart', () => { 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( expect(
shouldPurgeDashboardChannelOnStart({ shouldPurgeDashboardChannelOnStart({
purgeOnStart: true, purgeOnStart: true,
storedMessageId: 'status-message-1', storedMessageId: 'status-message-1',
}), }),
).toBe(false); ).toBe(true);
}); });
it('purges only when explicitly requested and no stored message id exists', () => { it('does not purge when startup purge is disabled', () => {
expect(
shouldPurgeDashboardChannelOnStart({
purgeOnStart: true,
storedMessageId: null,
}),
).toBe(true);
expect( expect(
shouldPurgeDashboardChannelOnStart({ shouldPurgeDashboardChannelOnStart({
purgeOnStart: false, purgeOnStart: false,

View File

@@ -41,6 +41,10 @@ import {
type DashboardRoomLine, type DashboardRoomLine,
renderCategorizedRoomSections, renderCategorizedRoomSections,
} from './dashboard-render.js'; } from './dashboard-render.js';
import {
cleanupDashboardDuplicateMessages,
purgeDashboardMessages,
} from './dashboard-message-cleanup.js';
import { import {
buildClaudeUsageRows, buildClaudeUsageRows,
extractCodexUsageRows, extractCodexUsageRows,
@@ -157,25 +161,14 @@ function findDiscordChannel(channels: Channel[]): Channel | undefined {
export async function purgeDashboardChannel( export async function purgeDashboardChannel(
opts: Pick<UnifiedDashboardOptions, 'channels' | 'statusChannelId'>, opts: Pick<UnifiedDashboardOptions, 'channels' | 'statusChannelId'>,
): Promise<void> { ): Promise<void> {
if (!opts.statusChannelId) return; await purgeDashboardMessages(opts);
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);
}
} }
export function shouldPurgeDashboardChannelOnStart(args: { export function shouldPurgeDashboardChannelOnStart(args: {
purgeOnStart?: boolean; purgeOnStart?: boolean;
storedMessageId: string | null; storedMessageId: string | null;
}): boolean { }): boolean {
return args.purgeOnStart === true && !args.storedMessageId; return args.purgeOnStart === true;
} }
async function refreshChannelMeta( async function refreshChannelMeta(
@@ -729,6 +722,7 @@ export async function startUnifiedDashboard(
}) })
) { ) {
await purgeDashboardChannel(opts); await purgeDashboardChannel(opts);
statusMessageId = null;
} }
if (isRenderer) { if (isRenderer) {
@@ -787,6 +781,9 @@ export async function startUnifiedDashboard(
writeDashboardStatusMessageId(opts.statusChannelId, id); writeDashboardStatusMessageId(opts.statusChannelId, id);
} }
} }
if (statusMessageId) {
await cleanupDashboardDuplicateMessages(opts, statusMessageId);
}
if (!dashboardUpdateLogged) { if (!dashboardUpdateLogged) {
logger.info( logger.info(
{ messageId: statusMessageId, contentLength: content.length }, { messageId: statusMessageId, contentLength: content.length },