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:
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,
|
||||
|
||||
Reference in New Issue
Block a user