fix: clean duplicate dashboard status messages
This commit is contained in:
90
src/channels/discord-message-cleanup.test.ts
Normal file
90
src/channels/discord-message-cleanup.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
81
src/channels/discord-message-cleanup.ts
Normal file
81
src/channels/discord-message-cleanup.ts
Normal 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;
|
||||
}
|
||||
@@ -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<number> {
|
||||
return deleteRecentDiscordMessagesByContent({
|
||||
client: this.client,
|
||||
channelName: this.name,
|
||||
jid,
|
||||
options,
|
||||
});
|
||||
}
|
||||
|
||||
async editMessage(
|
||||
jid: string,
|
||||
messageId: string,
|
||||
|
||||
88
src/dashboard-message-cleanup.test.ts
Normal file
88
src/dashboard-message-cleanup.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
67
src/dashboard-message-cleanup.ts
Normal file
67
src/dashboard-message-cleanup.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
10
src/types.ts
10
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<void>;
|
||||
deleteMessage?(jid: string, messageId: string): Promise<void>;
|
||||
deleteRecentMessagesByContent?(
|
||||
jid: string,
|
||||
options: DeleteRecentMessagesByContentOptions,
|
||||
): Promise<number>;
|
||||
sendAndTrack?(jid: string, text: string): Promise<string | null>;
|
||||
// Optional: sync group/chat names from the platform.
|
||||
syncGroups?(force: boolean): Promise<void>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<UnifiedDashboardOptions, 'channels' | 'statusChannelId'>,
|
||||
): Promise<void> {
|
||||
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 },
|
||||
|
||||
Reference in New Issue
Block a user