clear webhooks command
This commit is contained in:
parent
1fb9aa5ed9
commit
5241b634e2
4 changed files with 363 additions and 18 deletions
|
|
@ -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: [] };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, "\\$&");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue