Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@

- Fix ClawHub appeals accepting client-supplied account context, and revalidate new and pending appeals against the authenticated GitHub applicant before unbanning. Thanks @SebTardif for the report and intake fix.
- Reject crafted GitHub summary owner/repository paths before authenticated requests while preserving configured installation access. Thanks @SebTardif for the report and path validation fix.
- Avoid duplicate channel webhooks when concurrent automod events reach an empty cache in the same Worker instance.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ The Worker must have the matching public key:
bunx wrangler secret put FORWARDER_PUBLIC_KEY
```

## Automod webhooks

Concurrent automod events in the same Worker instance share the channel webhook lookup and creation. Failed attempts are cleared so a later event can retry; successful webhooks retain the existing 15-minute cache. Separate Worker instances still manage their own caches.

## Notes

- Answer Overflow base URL is hardcoded to `https://www.answeroverflow.com`.
Expand Down
32 changes: 23 additions & 9 deletions src/utils/channelWebhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "@buape/carbon"

const webhookCache = new Map<string, { webhook: Webhook; fetchedAt: number }>()
const pendingWebhooks = new Map<string, Promise<Webhook>>()
const webhookCacheTtlMs = 15 * 60 * 1000

const cleanupWebhookCache = () => {
Expand Down Expand Up @@ -42,17 +43,30 @@ export const getOrCreateChannelWebhook = async (
return cached.webhook
}

const existingWebhooks = await fetchChannelWebhooks(client, channelId)
const usableWebhook = existingWebhooks.find((webhook) => webhook.token)
const webhookData = usableWebhook ?? (await createChannelWebhook(client, channelId, name))

if (!webhookData.token) {
throw new Error("Webhook token missing for channel repost")
const pending = pendingWebhooks.get(channelId)
if (pending) {
return pending
}

const webhook = new Webhook({ id: webhookData.id, token: webhookData.token })
webhookCache.set(channelId, { webhook, fetchedAt: Date.now() })
return webhook
const request = (async () => {
const existingWebhooks = await fetchChannelWebhooks(client, channelId)
const usableWebhook = existingWebhooks.find((webhook) => webhook.token)
const webhookData = usableWebhook ?? (await createChannelWebhook(client, channelId, name))

if (!webhookData.token) {
throw new Error("Webhook token missing for channel repost")
}

const webhook = new Webhook({ id: webhookData.id, token: webhookData.token })
webhookCache.set(channelId, { webhook, fetchedAt: Date.now() })
return webhook
})()
pendingWebhooks.set(channelId, request)
try {
return await request
} finally {
pendingWebhooks.delete(channelId)
}
}

export const sendWebhookMessage = async (
Expand Down
72 changes: 72 additions & 0 deletions tests/channelWebhook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, it, mock } from "bun:test"
import type { Client } from "@buape/carbon"
import { getOrCreateChannelWebhook } from "../src/utils/channelWebhook.js"

const webhookData = { id: "123", token: "synthetic-webhook-token" }
const makeClient = (get: () => Promise<unknown>, post = async () => webhookData) => {
const rest = { get: mock(get), post: mock(post) }
return { client: { rest } as unknown as Client, rest }
}

describe("channel webhook cache", () => {
it("shares lookup and creation among simultaneous callers", async () => {
const { client, rest } = makeClient(async () => [])
const webhooks = await Promise.all(Array.from({ length: 20 }, () =>
getOrCreateChannelWebhook(client, "concurrent-create")
))
expect(rest.get).toHaveBeenCalledTimes(1)
expect(rest.post).toHaveBeenCalledTimes(1)
expect(new Set(webhooks).size).toBe(1)
expect(await getOrCreateChannelWebhook(client, "concurrent-create")).toBe(webhooks[0])
expect(rest.get).toHaveBeenCalledTimes(1)
})

it("shares an existing webhook without creating another", async () => {
const { client, rest } = makeClient(async () => [webhookData])
const webhooks = await Promise.all([
getOrCreateChannelWebhook(client, "concurrent-existing"),
getOrCreateChannelWebhook(client, "concurrent-existing")
])
expect(rest.get).toHaveBeenCalledTimes(1)
expect(rest.post).not.toHaveBeenCalled()
expect(webhooks[0]).toBe(webhooks[1])
})

for (const stage of ["lookup", "create", "missing-token"] as const) {
it(`releases failed ${stage} attempts so later events can retry`, async () => {
let fail = true
const { client, rest } = makeClient(async () => {
if (fail && stage === "lookup") throw new Error("lookup unavailable")
return []
}, async () => {
if (fail && stage === "create") throw new Error("create unavailable")
return fail && stage === "missing-token" ? { id: "123", token: "" } : webhookData
})
const channel = `retry-${stage}`
const results = await Promise.allSettled([
getOrCreateChannelWebhook(client, channel),
getOrCreateChannelWebhook(client, channel)
])
expect(results.map((result) => result.status)).toEqual(["rejected", "rejected"])
expect(rest.get).toHaveBeenCalledTimes(1)
fail = false
expect((await getOrCreateChannelWebhook(client, channel)).id).toBe(webhookData.id)
expect(rest.get).toHaveBeenCalledTimes(2)
})
}

it("does not block another channel behind a pending lookup", async () => {
let release = (_: unknown[]) => {}
const pending = new Promise<unknown[]>((resolve) => { release = resolve })
const slow = makeClient(() => pending)
const fast = makeClient(async () => [webhookData])
const slowRequest = getOrCreateChannelWebhook(slow.client, "slow-channel")
try {
expect((await getOrCreateChannelWebhook(fast.client, "fast-channel")).id).toBe(webhookData.id)
expect(slow.rest.post).not.toHaveBeenCalled()
} finally {
release([])
await slowRequest
}
})
})