See the best posts from any Bluesky account
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add DiscordWebhookService for firehose virality webhooks

Builds the Discord embed payload (username, title, URL, description,
estimated likes / threshold fields) and POSTs it with a 3-attempt
exponential backoff. On final failure throws DiscordWebhookError so the
caller can decide whether to log or swallow it. Injects fetch and sleep
so unit tests run offline.

Also exports atUriToBskyAppUrl, the at:// -> bsky.app conversion helper.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+348
+137
app/services/discord_webhook.ts
··· 1 + /** 2 + * Discord webhook service — fires an embed for a firehose virality threshold 3 + * crossing. 4 + * 5 + * POSTs JSON to the configured webhook URL. Retries up to 3 times total with 6 + * exponential backoff (500ms, 1s). On final failure, throws 7 + * `DiscordWebhookError` so the caller can log / capture it. 8 + * 9 + * Both `fetch` and `sleep` are injected so the unit tests run deterministically 10 + * and don't touch the network. 11 + */ 12 + 13 + // --------------------------------------------------------------------------- 14 + // Types 15 + // --------------------------------------------------------------------------- 16 + 17 + export interface DiscordWebhookSendParams { 18 + webhookUrl: string 19 + subjectUri: string 20 + threshold: number 21 + estimatedCount: number 22 + authorHandle: string 23 + postText: string 24 + } 25 + 26 + export interface DiscordWebhookServiceDeps { 27 + fetchImpl?: typeof fetch 28 + sleep?: (ms: number) => Promise<void> 29 + } 30 + 31 + export class DiscordWebhookError extends Error { 32 + constructor( 33 + message: string, 34 + readonly attempts: number, 35 + readonly cause?: unknown 36 + ) { 37 + super(message, { cause }) 38 + this.name = 'DiscordWebhookError' 39 + } 40 + } 41 + 42 + // --------------------------------------------------------------------------- 43 + // Helpers 44 + // --------------------------------------------------------------------------- 45 + 46 + const BACKOFF_DELAYS_MS = [500, 1000, 2000] 47 + const DESCRIPTION_MAX_CHARS = 500 48 + 49 + /** 50 + * Convert an AT-URI of shape `at://{did}/app.bsky.feed.post/{rkey}` to the 51 + * corresponding bsky.app web URL. 52 + */ 53 + export function atUriToBskyAppUrl(atUri: string): string { 54 + const match = atUri.match(/^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/(.+)$/) 55 + if (!match) { 56 + throw new Error(`atUriToBskyAppUrl: not a post AT-URI: ${atUri}`) 57 + } 58 + return `https://bsky.app/profile/${match[1]}/post/${match[2]}` 59 + } 60 + 61 + function formatNumber(n: number): string { 62 + return n.toLocaleString('en-US') 63 + } 64 + 65 + function truncate(text: string, maxChars: number): string { 66 + if (text.length <= maxChars) return text 67 + return text.slice(0, maxChars) + '…' 68 + } 69 + 70 + function buildPayload(params: DiscordWebhookSendParams): Record<string, unknown> { 71 + return { 72 + username: 'favs.blue firehose', 73 + embeds: [ 74 + { 75 + author: { name: `@${params.authorHandle}` }, 76 + title: `Post crossed ${formatNumber(params.threshold)} likes`, 77 + url: atUriToBskyAppUrl(params.subjectUri), 78 + description: truncate(params.postText, DESCRIPTION_MAX_CHARS), 79 + color: 3447003, 80 + timestamp: new Date().toISOString(), 81 + fields: [ 82 + { name: 'Estimated likes', value: formatNumber(params.estimatedCount), inline: true }, 83 + { name: 'Threshold', value: formatNumber(params.threshold), inline: true }, 84 + ], 85 + }, 86 + ], 87 + } 88 + } 89 + 90 + // --------------------------------------------------------------------------- 91 + // Service 92 + // --------------------------------------------------------------------------- 93 + 94 + export class DiscordWebhookService { 95 + readonly #fetch: typeof fetch 96 + readonly #sleep: (ms: number) => Promise<void> 97 + 98 + constructor(deps: DiscordWebhookServiceDeps = {}) { 99 + this.#fetch = deps.fetchImpl ?? globalThis.fetch.bind(globalThis) 100 + this.#sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))) 101 + } 102 + 103 + /** 104 + * POST the Discord embed payload. Retries up to 3 total attempts on network 105 + * error or HTTP >= 400. Throws `DiscordWebhookError` if all attempts fail. 106 + */ 107 + async send(params: DiscordWebhookSendParams): Promise<void> { 108 + const payload = buildPayload(params) 109 + const body = JSON.stringify(payload) 110 + 111 + let lastError: unknown 112 + for (let attempt = 0; attempt < BACKOFF_DELAYS_MS.length; attempt++) { 113 + try { 114 + const response = await this.#fetch(params.webhookUrl, { 115 + method: 'POST', 116 + headers: { 'Content-Type': 'application/json' }, 117 + body, 118 + }) 119 + if (response.ok) return 120 + lastError = new Error(`Discord webhook returned HTTP ${response.status}`) 121 + } catch (err) { 122 + lastError = err 123 + } 124 + 125 + if (attempt < BACKOFF_DELAYS_MS.length - 1) { 126 + await this.#sleep(BACKOFF_DELAYS_MS[attempt]) 127 + } 128 + } 129 + 130 + const detail = lastError instanceof Error ? lastError.message : String(lastError) 131 + throw new DiscordWebhookError( 132 + `Discord webhook failed after ${BACKOFF_DELAYS_MS.length} attempts: ${detail}`, 133 + BACKOFF_DELAYS_MS.length, 134 + lastError 135 + ) 136 + } 137 + }
+211
tests/unit/services/discord_webhook.spec.ts
··· 1 + /** 2 + * Unit tests for DiscordWebhookService. 3 + * 4 + * Fetch is injected as a fake so no network traffic occurs. 5 + */ 6 + import { test } from '@japa/runner' 7 + import { 8 + DiscordWebhookService, 9 + atUriToBskyAppUrl, 10 + DiscordWebhookError, 11 + } from '#services/discord_webhook' 12 + 13 + // --------------------------------------------------------------------------- 14 + // Helpers 15 + // --------------------------------------------------------------------------- 16 + 17 + type FetchCall = { url: string; body: unknown } 18 + 19 + function makeFakeFetch(responses: Array<{ ok: boolean; status: number } | Error>): { 20 + fn: typeof fetch 21 + calls: FetchCall[] 22 + } { 23 + const calls: FetchCall[] = [] 24 + let idx = 0 25 + const fn = (async (url: string, init?: RequestInit) => { 26 + calls.push({ 27 + url, 28 + body: init?.body ? JSON.parse(init.body as string) : undefined, 29 + }) 30 + const response = responses[idx++] 31 + if (!response) throw new Error('Unexpected fetch call (no response queued)') 32 + if (response instanceof Error) throw response 33 + return { 34 + ok: response.ok, 35 + status: response.status, 36 + text: async () => '', 37 + } 38 + }) as unknown as typeof fetch 39 + return { fn, calls } 40 + } 41 + 42 + // --------------------------------------------------------------------------- 43 + // atUriToBskyAppUrl 44 + // --------------------------------------------------------------------------- 45 + 46 + test.group('atUriToBskyAppUrl', () => { 47 + test('converts AT-URI to bsky.app URL', ({ assert }) => { 48 + const uri = 'at://did:plc:abc123/app.bsky.feed.post/3k44xyz' 49 + assert.equal(atUriToBskyAppUrl(uri), 'https://bsky.app/profile/did:plc:abc123/post/3k44xyz') 50 + }) 51 + 52 + test('throws on malformed URI', ({ assert }) => { 53 + assert.throws(() => atUriToBskyAppUrl('not-an-at-uri')) 54 + assert.throws(() => atUriToBskyAppUrl('at://did:plc:abc/app.bsky.feed.post/')) 55 + }) 56 + }) 57 + 58 + // --------------------------------------------------------------------------- 59 + // Payload shape 60 + // --------------------------------------------------------------------------- 61 + 62 + test.group('DiscordWebhookService payload shape', () => { 63 + test('builds the expected embed', async ({ assert }) => { 64 + const { fn, calls } = makeFakeFetch([{ ok: true, status: 204 }]) 65 + const svc = new DiscordWebhookService({ fetchImpl: fn, sleep: () => Promise.resolve() }) 66 + 67 + await svc.send({ 68 + webhookUrl: 'https://discord.example/hook', 69 + subjectUri: 'at://did:plc:abc/app.bsky.feed.post/rkey1', 70 + threshold: 1000, 71 + estimatedCount: 1037, 72 + authorHandle: 'joe.bsky.social', 73 + postText: 'hello world', 74 + }) 75 + 76 + assert.equal(calls.length, 1) 77 + assert.equal(calls[0].url, 'https://discord.example/hook') 78 + const body = calls[0].body as any 79 + assert.equal(body.username, 'favs.blue firehose') 80 + assert.equal(body.embeds.length, 1) 81 + const embed = body.embeds[0] 82 + assert.equal(embed.author.name, '@joe.bsky.social') 83 + assert.equal(embed.title, 'Post crossed 1,000 likes') 84 + assert.equal(embed.url, 'https://bsky.app/profile/did:plc:abc/post/rkey1') 85 + assert.equal(embed.description, 'hello world') 86 + assert.equal(embed.color, 3447003) 87 + assert.isString(embed.timestamp) 88 + assert.deepEqual(embed.fields, [ 89 + { name: 'Estimated likes', value: '1,037', inline: true }, 90 + { name: 'Threshold', value: '1,000', inline: true }, 91 + ]) 92 + }) 93 + 94 + test('truncates post text to ~500 chars with ellipsis', async ({ assert }) => { 95 + const { fn, calls } = makeFakeFetch([{ ok: true, status: 204 }]) 96 + const svc = new DiscordWebhookService({ fetchImpl: fn, sleep: () => Promise.resolve() }) 97 + const longText = 'a'.repeat(600) 98 + 99 + await svc.send({ 100 + webhookUrl: 'https://discord.example/hook', 101 + subjectUri: 'at://did:plc:abc/app.bsky.feed.post/rkey1', 102 + threshold: 10000, 103 + estimatedCount: 12500, 104 + authorHandle: 'joe.bsky.social', 105 + postText: longText, 106 + }) 107 + 108 + const body = calls[0].body as any 109 + const desc = body.embeds[0].description as string 110 + assert.isTrue(desc.length <= 501, `expected <=501 chars, got ${desc.length}`) 111 + assert.isTrue(desc.endsWith('…')) 112 + assert.equal(body.embeds[0].title, 'Post crossed 10,000 likes') 113 + assert.equal(body.embeds[0].fields[0].value, '12,500') 114 + assert.equal(body.embeds[0].fields[1].value, '10,000') 115 + }) 116 + 117 + test('leaves short text untouched', async ({ assert }) => { 118 + const { fn, calls } = makeFakeFetch([{ ok: true, status: 204 }]) 119 + const svc = new DiscordWebhookService({ fetchImpl: fn, sleep: () => Promise.resolve() }) 120 + 121 + await svc.send({ 122 + webhookUrl: 'https://discord.example/hook', 123 + subjectUri: 'at://did:plc:abc/app.bsky.feed.post/rkey1', 124 + threshold: 1000, 125 + estimatedCount: 1000, 126 + authorHandle: 'a.bsky.social', 127 + postText: 'short', 128 + }) 129 + 130 + const body = calls[0].body as any 131 + assert.equal(body.embeds[0].description, 'short') 132 + }) 133 + }) 134 + 135 + // --------------------------------------------------------------------------- 136 + // Retry behavior 137 + // --------------------------------------------------------------------------- 138 + 139 + test.group('DiscordWebhookService retry', () => { 140 + test('retries on 500 and eventually succeeds', async ({ assert }) => { 141 + const { fn, calls } = makeFakeFetch([ 142 + { ok: false, status: 500 }, 143 + { ok: false, status: 502 }, 144 + { ok: true, status: 204 }, 145 + ]) 146 + const sleepCalls: number[] = [] 147 + const svc = new DiscordWebhookService({ 148 + fetchImpl: fn, 149 + sleep: (ms) => { 150 + sleepCalls.push(ms) 151 + return Promise.resolve() 152 + }, 153 + }) 154 + 155 + await svc.send({ 156 + webhookUrl: 'https://discord.example/hook', 157 + subjectUri: 'at://did:plc:abc/app.bsky.feed.post/rkey1', 158 + threshold: 1000, 159 + estimatedCount: 1000, 160 + authorHandle: 'a.bsky.social', 161 + postText: 'x', 162 + }) 163 + 164 + assert.equal(calls.length, 3) 165 + // two backoff waits between three attempts 166 + assert.equal(sleepCalls.length, 2) 167 + assert.equal(sleepCalls[0], 500) 168 + assert.equal(sleepCalls[1], 1000) 169 + }) 170 + 171 + test('retries on network error and throws DiscordWebhookError after 3 failures', async ({ 172 + assert, 173 + }) => { 174 + const { fn, calls } = makeFakeFetch([ 175 + new Error('ECONNRESET'), 176 + { ok: false, status: 500 }, 177 + { ok: false, status: 503 }, 178 + ]) 179 + const svc = new DiscordWebhookService({ fetchImpl: fn, sleep: () => Promise.resolve() }) 180 + 181 + await assert.rejects( 182 + () => 183 + svc.send({ 184 + webhookUrl: 'https://discord.example/hook', 185 + subjectUri: 'at://did:plc:abc/app.bsky.feed.post/rkey1', 186 + threshold: 1000, 187 + estimatedCount: 1000, 188 + authorHandle: 'a.bsky.social', 189 + postText: 'x', 190 + }), 191 + DiscordWebhookError 192 + ) 193 + assert.equal(calls.length, 3) 194 + }) 195 + 196 + test('does not retry on success', async ({ assert }) => { 197 + const { fn, calls } = makeFakeFetch([{ ok: true, status: 204 }]) 198 + const svc = new DiscordWebhookService({ fetchImpl: fn, sleep: () => Promise.resolve() }) 199 + 200 + await svc.send({ 201 + webhookUrl: 'https://discord.example/hook', 202 + subjectUri: 'at://did:plc:abc/app.bsky.feed.post/rkey1', 203 + threshold: 1000, 204 + estimatedCount: 1000, 205 + authorHandle: 'a.bsky.social', 206 + postText: 'x', 207 + }) 208 + 209 + assert.equal(calls.length, 1) 210 + }) 211 + })