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:
@@ -1,9 +1,67 @@
|
|||||||
import { prisma } from './db.js';
|
import { prisma } from './db.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
|
||||||
export async function dispatchWebhooks(tenantId: string, event: string, data: any) {
|
export async function dispatchWebhooks(tenantId: string, event: string, data: any) {
|
||||||
// Don't await this - fire and forget
|
// 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) {
|
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 } }
|
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) {
|
for (const webhook of webhooks) {
|
||||||
const signature = crypto.createHmac('sha256', webhook.secret).update(payload).digest('hex');
|
const isDiscord = isDiscordWebhook(webhook.url);
|
||||||
|
|
||||||
fetch(webhook.url, {
|
let body: string;
|
||||||
method: 'POST',
|
let headers: Record<string, string>;
|
||||||
headers: {
|
|
||||||
|
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',
|
'Content-Type': 'application/json',
|
||||||
'X-Pounce-Signature': signature,
|
'X-Pounce-Signature': signature,
|
||||||
'X-Pounce-Event': event
|
'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');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user