Private
Public Access
1
0

Add Discord webhook formatting and dispatch logging

Discord webhooks require a specific payload format (embeds), not raw
JSON. Auto-detect Discord URLs and send colored embeds with card/board
details. Also adds logging for webhook dispatch debugging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-03-08 11:00:06 -04:00
parent a57f83c5cd
commit b6d65b7c31
+87 -9
View File
@@ -1,9 +1,67 @@
import { prisma } from './db.js';
import { logger } from './logger.js';
import crypto from 'crypto';
export async function dispatchWebhooks(tenantId: string, event: string, data: any) {
// Don't await this - fire and forget
doDispatch(tenantId, event, data).catch(() => {});
doDispatch(tenantId, event, data).catch((err) => {
logger.error({ err, tenantId, event }, 'webhook dispatch failed');
});
}
function isDiscordWebhook(url: string): boolean {
return url.startsWith('https://discord.com/api/webhooks/') ||
url.startsWith('https://discordapp.com/api/webhooks/');
}
function formatDiscordPayload(event: string, data: any): object {
const colors: Record<string, number> = {
'card:created': 0x22c55e, // green
'card:updated': 0x3b82f6, // blue
'card:moved': 0xf59e0b, // amber
'card:deleted': 0xef4444, // red
'column:created': 0x10b981, // emerald
'column:deleted': 0xf97316, // orange
'board:updated': 0x8b5cf6, // purple
};
const titles: Record<string, string> = {
'card:created': 'Card Created',
'card:updated': 'Card Updated',
'card:moved': 'Card Moved',
'card:deleted': 'Card Deleted',
'column:created': 'Column Created',
'column:deleted': 'Column Deleted',
'board:updated': 'Board Updated',
};
const fields: { name: string; value: string; inline?: boolean }[] = [];
if (data.card) {
fields.push({ name: 'Card', value: data.card.title || data.card.id || 'Unknown', inline: true });
}
if (data.column) {
fields.push({ name: 'Column', value: data.column.title || data.column.id || 'Unknown', inline: true });
}
if (data.board) {
fields.push({ name: 'Board', value: data.board.title || data.board.id || 'Unknown', inline: true });
}
if (event === 'card:deleted') {
fields.push({ name: 'Card ID', value: data.cardId || 'Unknown', inline: true });
}
if (event === 'column:deleted') {
fields.push({ name: 'Column ID', value: data.columnId || 'Unknown', inline: true });
}
return {
embeds: [{
title: titles[event] || event,
color: colors[event] || 0x6b7280,
fields,
timestamp: new Date().toISOString(),
footer: { text: 'Pounce' }
}]
};
}
async function doDispatch(tenantId: string, event: string, data: any) {
@@ -11,19 +69,39 @@ async function doDispatch(tenantId: string, event: string, data: any) {
where: { tenantId, active: true, events: { has: event } }
});
const payload = JSON.stringify({ event, data, timestamp: new Date().toISOString() });
logger.info({ tenantId, event, webhookCount: webhooks.length }, 'dispatching webhooks');
if (webhooks.length === 0) return;
for (const webhook of webhooks) {
const signature = crypto.createHmac('sha256', webhook.secret).update(payload).digest('hex');
const isDiscord = isDiscordWebhook(webhook.url);
fetch(webhook.url, {
method: 'POST',
headers: {
let body: string;
let headers: Record<string, string>;
if (isDiscord) {
body = JSON.stringify(formatDiscordPayload(event, data));
headers = { 'Content-Type': 'application/json' };
} else {
body = JSON.stringify({ event, data, timestamp: new Date().toISOString() });
const signature = crypto.createHmac('sha256', webhook.secret).update(body).digest('hex');
headers = {
'Content-Type': 'application/json',
'X-Pounce-Signature': signature,
'X-Pounce-Event': event
},
body: payload
}).catch(() => {});
};
}
fetch(webhook.url, { method: 'POST', headers, body })
.then((res) => {
if (!res.ok) {
res.text().then((text) => {
logger.warn({ webhookId: webhook.id, status: res.status, event, response: text.slice(0, 200) }, 'webhook delivery failed');
});
}
})
.catch((err) => {
logger.warn({ err, webhookId: webhook.id, event }, 'webhook delivery error');
});
}
}