From 5241b634e223880a22b090d3e18370e5b9ee5adb Mon Sep 17 00:00:00 2001 From: matamune Date: Wed, 13 May 2026 00:42:10 +0000 Subject: [PATCH] clear webhooks command --- apps/discord-bridge/src/bridge.ts | 60 +++++++ apps/discord-bridge/src/discord-transport.ts | 135 +++++++++++++-- apps/discord-bridge/src/types.ts | 17 +- apps/discord-bridge/test/bridge.test.ts | 169 ++++++++++++++++++- 4 files changed, 363 insertions(+), 18 deletions(-) diff --git a/apps/discord-bridge/src/bridge.ts b/apps/discord-bridge/src/bridge.ts index 0a8759e..2a5d543 100644 --- a/apps/discord-bridge/src/bridge.ts +++ b/apps/discord-bridge/src/bridge.ts @@ -18,6 +18,7 @@ import type { DiscordBridgeStateStore, DiscordBridgeTransport, DiscordClearInbound, + DiscordClearWebhooksInbound, DiscordInbound, DiscordMessageInbound, DiscordThreadStartInbound, @@ -178,6 +179,10 @@ export class DiscordCodexBridge { await this.#handleClear(inbound); return; } + if (inbound.kind === "clearWebhooks") { + await this.#handleClearWebhooks(inbound); + return; + } if (inbound.kind === "threadStart") { if (!this.config.allowedUserIds.has(inbound.author.id)) { @@ -276,6 +281,49 @@ export class DiscordCodexBridge { })); } + async #handleClearWebhooks(command: DiscordClearWebhooksInbound): Promise { + if (!this.config.allowedUserIds.has(command.author.id)) { + this.#debug("clearWebhooks.ignored.user", { + channelId: command.channelId, + authorId: command.author.id, + }); + await command.reply?.( + "Only globally allowed Discord users can clear webhook messages.", + ); + return; + } + if (!this.transport.deleteWebhookMessages) { + this.#debug("clearWebhooks.unsupported", { channelId: command.channelId }); + await command.reply?.("This Discord transport cannot delete webhook messages."); + return; + } + this.#debug("clearWebhooks.start", { + channelId: command.channelId, + guildId: command.guildId, + filtered: Boolean(command.webhookUrl), + }); + let result: { deleted: number; failed: number }; + try { + result = await this.transport.deleteWebhookMessages(command.channelId, { + webhookUrl: command.webhookUrl, + }); + } catch (error) { + const message = errorMessage(error); + this.#debug("clearWebhooks.failed", { + channelId: command.channelId, + error: message, + }); + await command.reply?.(`Failed to clear webhook messages: ${message}`); + return; + } + this.#debug("clearWebhooks.complete", { + channelId: command.channelId, + deleted: result.deleted, + failed: result.failed, + }); + await command.reply?.(clearWebhooksSummary(result)); + } + async #handleThreadStart(start: DiscordThreadStartInbound): Promise { const state = this.#requireState(); if ( @@ -956,6 +1004,18 @@ function clearSummary(input: { return parts.join(" "); } +function clearWebhooksSummary(input: { deleted: number; failed: number }): string { + const parts = [ + `Deleted ${input.deleted} webhook message${input.deleted === 1 ? "" : "s"}.`, + ]; + if (input.failed > 0) { + parts.push( + `Failed to delete ${input.failed} webhook message${input.failed === 1 ? "" : "s"}.`, + ); + } + return parts.join(" "); +} + function emptyThreadSnapshot(): ThreadSnapshot { return { terminalTurnIds: [] }; } diff --git a/apps/discord-bridge/src/discord-transport.ts b/apps/discord-bridge/src/discord-transport.ts index bb40692..9fbe0d7 100644 --- a/apps/discord-bridge/src/discord-transport.ts +++ b/apps/discord-bridge/src/discord-transport.ts @@ -78,6 +78,18 @@ export class DiscordJsBridgeTransport implements DiscordBridgeTransport { name: "clear", description: "Delete inactive Codex bridge threads", }, + { + name: "clear-webhooks", + description: "Delete webhook-authored messages in this channel", + options: [ + { + name: "webhook_url", + description: "Optional webhook URL to target a single webhook", + type: 3, + required: false, + }, + ], + }, ]); } @@ -170,6 +182,54 @@ export class DiscordJsBridgeTransport implements DiscordBridgeTransport { await message.delete(); } + async deleteWebhookMessages( + channelId: string, + options: { webhookUrl?: string } = {}, + ): Promise<{ deleted: number; failed: number }> { + const channel = await this.#sendableChannel(channelId); + const messages = getMessagesManager(channel); + if (!messages) { + throw new Error(`Discord channel cannot fetch messages: ${channelId}`); + } + const webhookId = options.webhookUrl + ? webhookIdFromUrl(options.webhookUrl) + : undefined; + let before: string | undefined; + let deleted = 0; + let failed = 0; + for (;;) { + const batch = await messages.fetch({ limit: 100, before }); + const fetched = [...batch.values()]; + if (fetched.length === 0) { + break; + } + for (const message of fetched) { + if (!message.webhookId) { + continue; + } + if (webhookId && message.webhookId !== webhookId) { + continue; + } + try { + await message.delete(); + deleted += 1; + } catch (error) { + failed += 1; + this.#logger.debug("discord.webhookMessage.deleteFailed", { + channelId, + messageId: message.id, + error: errorMessage(error), + }); + } + } + before = fetched[fetched.length - 1]?.id; + if (fetched.length < 100 || !before) { + break; + } + } + return { deleted, failed }; + } + async deleteThread(channelId: string): Promise { const client = this.#client; if (!client) { @@ -264,10 +324,47 @@ export class DiscordJsBridgeTransport implements DiscordBridgeTransport { } async #handleInteraction(interaction: Interaction): Promise { - if (!interaction.isChatInputCommand() || interaction.commandName !== "clear") { + if (!interaction.isChatInputCommand()) { + return; + } + if ( + interaction.commandName !== "clear" && + interaction.commandName !== "clear-webhooks" + ) { return; } const channelId = interaction.channelId; + const reply = async (text: string) => { + await interaction.reply({ + content: text, + ephemeral: true, + allowedMentions: { + parse: [], + users: [], + roles: [], + repliedUser: false, + }, + }); + }; + if (interaction.commandName === "clear-webhooks") { + const webhookUrl = interaction.options.getString("webhook_url") ?? undefined; + this.#handlers?.onInbound({ + kind: "clearWebhooks", + channelId, + guildId: interaction.guildId ?? undefined, + author: { + id: interaction.user.id, + name: interaction.member && "displayName" in interaction.member + ? String(interaction.member.displayName) + : interaction.user.globalName || interaction.user.username, + isBot: interaction.user.bot, + }, + webhookUrl, + createdAt: new Date().toISOString(), + reply, + }); + return; + } this.#handlers?.onInbound({ kind: "clear", channelId, @@ -280,18 +377,7 @@ export class DiscordJsBridgeTransport implements DiscordBridgeTransport { isBot: interaction.user.bot, }, createdAt: new Date().toISOString(), - reply: async (text) => { - await interaction.reply({ - content: text, - ephemeral: true, - allowedMentions: { - parse: [], - users: [], - roles: [], - repliedUser: false, - }, - }); - }, + reply, }); } @@ -325,14 +411,22 @@ type SendableChannel = { add(userId: string): Promise; }; messages?: { - fetch(messageId: string): Promise<{ + fetch(messageId: string): Promise; + fetch(options: { + limit: number; + before?: string; + }): Promise<{ values(): IterableIterator }>; + }; +}; + +type DiscordFetchedMessage = { + id: string; + webhookId?: string | null; delete(): Promise; edit(options: Record): Promise; pinned?: boolean; pin?(): Promise; startThread?(options: ThreadCreateOptions): Promise<{ id?: string }>; - }>; - }; }; function getThreadsManager( @@ -371,6 +465,15 @@ function stripUserMentions(content: string, userIds: string[]): string { return stripped.trim(); } +function webhookIdFromUrl(webhookUrl: string): string { + const parsed = new URL(webhookUrl); + const match = parsed.pathname.match(/\/webhooks\/(\d+)(?:\/|$)/); + if (!match?.[1]) { + throw new Error("Discord webhook URL does not include a webhook id"); + } + return match[1]; +} + function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } diff --git a/apps/discord-bridge/src/types.ts b/apps/discord-bridge/src/types.ts index 466c9ed..ac4f019 100644 --- a/apps/discord-bridge/src/types.ts +++ b/apps/discord-bridge/src/types.ts @@ -68,10 +68,21 @@ export type DiscordClearInbound = { reply?: (text: string) => Promise; }; +export type DiscordClearWebhooksInbound = { + kind: "clearWebhooks"; + channelId: string; + guildId?: string; + author: DiscordAuthor; + webhookUrl?: string; + createdAt: string; + reply?: (text: string) => Promise; +}; + export type DiscordInbound = | DiscordMessageInbound | DiscordThreadStartInbound - | DiscordClearInbound; + | DiscordClearInbound + | DiscordClearWebhooksInbound; export type DiscordBridgeTransportHandlers = { onInbound(inbound: DiscordInbound): void; @@ -89,6 +100,10 @@ export type DiscordBridgeTransport = { sendMessage(channelId: string, text: string): Promise; updateMessage?(channelId: string, messageId: string, text: string): Promise; deleteMessage(channelId: string, messageId: string): Promise; + deleteWebhookMessages?( + channelId: string, + options?: { webhookUrl?: string }, + ): Promise<{ deleted: number; failed: number }>; deleteThread?(channelId: string): Promise; addThreadMembers?(channelId: string, userIds: string[]): Promise; pinMessage?(channelId: string, messageId: string): Promise; diff --git a/apps/discord-bridge/test/bridge.test.ts b/apps/discord-bridge/test/bridge.test.ts index be23a77..6865d9b 100644 --- a/apps/discord-bridge/test/bridge.test.ts +++ b/apps/discord-bridge/test/bridge.test.ts @@ -1769,6 +1769,147 @@ describe("DiscordCodexBridge", () => { await bridge.stop(); }); + test("clear webhooks deletes webhook messages in the command channel", async () => { + const client = new FakeCodexClient(); + const transport = new FakeDiscordTransport(); + const replies: string[] = []; + transport.messages.push( + { + channelId: "parent-channel", + id: "message-webhook-1", + text: "bridged output", + webhookId: "webhook-1", + }, + { + channelId: "parent-channel", + id: "message-user-1", + text: "human message", + }, + { + channelId: "other-channel", + id: "message-webhook-2", + text: "other output", + webhookId: "webhook-1", + }, + ); + const bridge = new DiscordCodexBridge({ + client, + transport, + store: new MemoryStateStore(emptyState()), + config: testConfig(), + now: () => new Date("2026-05-11T00:00:00.000Z"), + }); + + await bridge.start(); + transport.emit({ + kind: "clearWebhooks", + channelId: "parent-channel", + author: { id: "user-1", name: "Ada", isBot: false }, + createdAt: "2026-05-11T00:00:00.000Z", + reply: async (text) => { + replies.push(text); + }, + }); + await waitFor(() => replies.length === 1); + + expect(transport.deletedMessages.map(({ channelId, messageId }) => ({ + channelId, + messageId, + }))).toEqual([{ channelId: "parent-channel", messageId: "message-webhook-1" }]); + expect(transport.messages.map((message) => message.id)).toEqual([ + "message-user-1", + "message-webhook-2", + ]); + expect(replies[0]).toBe("Deleted 1 webhook message."); + await bridge.stop(); + }); + + test("clear webhooks can filter by webhook url", async () => { + const client = new FakeCodexClient(); + const transport = new FakeDiscordTransport(); + const replies: string[] = []; + transport.messages.push( + { + channelId: "parent-channel", + id: "message-webhook-1", + text: "first output", + webhookId: "1234567890", + }, + { + channelId: "parent-channel", + id: "message-webhook-2", + text: "second output", + webhookId: "9876543210", + }, + ); + const bridge = new DiscordCodexBridge({ + client, + transport, + store: new MemoryStateStore(emptyState()), + config: testConfig(), + now: () => new Date("2026-05-11T00:00:00.000Z"), + }); + + await bridge.start(); + transport.emit({ + kind: "clearWebhooks", + channelId: "parent-channel", + author: { id: "user-1", name: "Ada", isBot: false }, + webhookUrl: "https://discord.com/api/webhooks/9876543210/token", + createdAt: "2026-05-11T00:00:00.000Z", + reply: async (text) => { + replies.push(text); + }, + }); + await waitFor(() => replies.length === 1); + + expect(transport.deletedMessages.map(({ messageId }) => messageId)).toEqual([ + "message-webhook-2", + ]); + expect(transport.messages.map((message) => message.id)).toEqual([ + "message-webhook-1", + ]); + expect(replies[0]).toBe("Deleted 1 webhook message."); + await bridge.stop(); + }); + + test("clear webhooks is restricted to global allowed users", async () => { + const client = new FakeCodexClient(); + const transport = new FakeDiscordTransport(); + const replies: string[] = []; + transport.messages.push({ + channelId: "parent-channel", + id: "message-webhook-1", + text: "bridged output", + webhookId: "webhook-1", + }); + const bridge = new DiscordCodexBridge({ + client, + transport, + store: new MemoryStateStore(emptyState()), + config: testConfig(), + now: () => new Date("2026-05-11T00:00:00.000Z"), + }); + + await bridge.start(); + transport.emit({ + kind: "clearWebhooks", + channelId: "parent-channel", + author: { id: "user-2", name: "Grace", isBot: false }, + createdAt: "2026-05-11T00:00:00.000Z", + reply: async (text) => { + replies.push(text); + }, + }); + await waitFor(() => replies.length === 1); + + expect(transport.deletedMessages).toEqual([]); + expect(replies[0]).toBe( + "Only globally allowed Discord users can clear webhook messages.", + ); + await bridge.stop(); + }); + test("continues existing managed Discord threads and dedupes messages", async () => { const client = new FakeCodexClient(); const transport = new FakeDiscordTransport(); @@ -2218,7 +2359,12 @@ class FakeDiscordTransport implements DiscordBridgeTransport { name: string; sourceMessageId?: string; }> = []; - messages: Array<{ channelId: string; id: string; text: string }> = []; + messages: Array<{ + channelId: string; + id: string; + text: string; + webhookId?: string; + }> = []; updatedMessages: Array<{ channelId: string; messageId: string; @@ -2279,6 +2425,27 @@ class FakeDiscordTransport implements DiscordBridgeTransport { } } + async deleteWebhookMessages( + channelId: string, + options: { webhookUrl?: string } = {}, + ): Promise<{ deleted: number; failed: number }> { + const webhookId = options.webhookUrl + ? options.webhookUrl.match(/\/webhooks\/([^/]+)/)?.[1] + : undefined; + let deleted = 0; + for (const message of [...this.messages]) { + if (message.channelId !== channelId || !message.webhookId) { + continue; + } + if (webhookId && message.webhookId !== webhookId) { + continue; + } + await this.deleteMessage(channelId, message.id); + deleted += 1; + } + return { deleted, failed: 0 }; + } + async deleteThread(channelId: string): Promise { this.deletedThreads.push(channelId); }