clear webhooks command

This commit is contained in:
matamune 2026-05-13 00:42:10 +00:00
parent 1fb9aa5ed9
commit 5241b634e2
Signed by: matamune
GPG key ID: 3BB8E7D3B968A324
4 changed files with 363 additions and 18 deletions

View file

@ -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<void> {
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<void> {
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: [] };
}

View file

@ -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<void> {
const client = this.#client;
if (!client) {
@ -264,10 +324,47 @@ export class DiscordJsBridgeTransport implements DiscordBridgeTransport {
}
async #handleInteraction(interaction: Interaction): Promise<void> {
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<unknown>;
};
messages?: {
fetch(messageId: string): Promise<{
fetch(messageId: string): Promise<DiscordFetchedMessage>;
fetch(options: {
limit: number;
before?: string;
}): Promise<{ values(): IterableIterator<DiscordFetchedMessage> }>;
};
};
type DiscordFetchedMessage = {
id: string;
webhookId?: string | null;
delete(): Promise<unknown>;
edit(options: Record<string, unknown>): Promise<unknown>;
pinned?: boolean;
pin?(): Promise<unknown>;
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, "\\$&");
}

View file

@ -68,10 +68,21 @@ export type DiscordClearInbound = {
reply?: (text: string) => Promise<void>;
};
export type DiscordClearWebhooksInbound = {
kind: "clearWebhooks";
channelId: string;
guildId?: string;
author: DiscordAuthor;
webhookUrl?: string;
createdAt: string;
reply?: (text: string) => Promise<void>;
};
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<string[]>;
updateMessage?(channelId: string, messageId: string, text: string): Promise<void>;
deleteMessage(channelId: string, messageId: string): Promise<void>;
deleteWebhookMessages?(
channelId: string,
options?: { webhookUrl?: string },
): Promise<{ deleted: number; failed: number }>;
deleteThread?(channelId: string): Promise<void>;
addThreadMembers?(channelId: string, userIds: string[]): Promise<void>;
pinMessage?(channelId: string, messageId: string): Promise<void>;

View file

@ -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<void> {
this.deletedThreads.push(channelId);
}