notXCS/lib/webhooks.js

146 lines
5 KiB
JavaScript

// Sends Discord webhook notifications for place-level events (access granted/denied, reader
// arm/disarm, reader enable/disable). Uses the runtime's built-in fetch() - no extra dependency.
//
// Each place has its own webhook config (see migrations/0009_discord_webhooks.sql):
// webhookUrl - the Discord webhook URL to POST to. If falsy, notifications are skipped.
// webhookContent - optional plain-text `content` sent alongside the embed (e.g. "@here" or
// a role/user mention). Purely cosmetic - never affects delivery logic.
// webhookAccessGranted - toggle for access_granted events
// webhookAccessDenied - toggle for access_denied events
// webhookReaderArm - toggle for both reader_armed and reader_disarmed events
// webhookReaderEnable - toggle for both reader_enabled and reader_disabled events
//
// Sending is entirely fire-and-forget: failures are logged but never thrown, so a broken/rate-limited
// webhook can never affect scan results or the dashboard's own API responses.
// Discord embed side colors (decimal), matching the dashboard's --success/--danger/--accent palette.
const COLORS = {
GREEN: 0x3ecf8e,
RED: 0xe5484d,
BLUE: 0x4f7cff,
ORANGE: 0xf5a623
};
// Maps each event type to the place column that gates it, plus how to render its embed.
const EVENT_CONFIG = {
access_granted: {
toggleColumn: 'webhookAccessGranted',
title: '✅ Access granted',
color: COLORS.GREEN
},
access_denied: {
toggleColumn: 'webhookAccessDenied',
title: '⛔ Access denied',
color: COLORS.RED
},
reader_armed: {
toggleColumn: 'webhookReaderArm',
title: '🔒 Reader armed',
color: COLORS.BLUE
},
reader_disarmed: {
toggleColumn: 'webhookReaderArm',
title: '🔓 Reader disarmed',
color: COLORS.ORANGE
},
reader_enabled: {
toggleColumn: 'webhookReaderEnable',
title: '🟢 Reader enabled',
color: COLORS.GREEN
},
reader_disabled: {
toggleColumn: 'webhookReaderEnable',
title: '🔴 Reader disabled',
color: COLORS.RED
}
};
// Builds the Discord embed `fields` array from a plain object, skipping any nullish values.
// Values are stringified and truncated to Discord's 1024-char field value limit.
function buildFields(data) {
return Object.entries(data)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.map(([name, value]) => ({
name,
value: String(value).slice(0, 1024),
inline: true
}));
}
// POSTs a raw payload to a Discord webhook URL using the runtime's built-in fetch(). Returns
// `{ ok: true }` on a 2xx response, or `{ ok: false, message }` otherwise. Never throws.
async function postToDiscord(webhookUrl, payload) {
try {
const res = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const body = await res.text().catch(() => '');
return { ok: false, message: `Discord responded with ${res.status}${body ? `: ${body}` : ''}` };
}
return { ok: true };
} catch (err) {
return { ok: false, message: err.message };
}
}
// Fires a Discord webhook for `eventType` on behalf of `place`, if it has a webhook URL configured
// and has that event type's toggle enabled. `fields` is a plain object of embed field name -> value
// (e.g. { Reader: 'Front Door', User: '123456' }). Never throws - logs and resolves on any failure.
async function sendPlaceWebhook(place, eventType, fields = {}) {
const config = EVENT_CONFIG[eventType];
if (!config) {
console.error(`Unknown webhook event type: ${eventType}`);
return;
}
if (!place || !place.webhookUrl || !place[config.toggleColumn]) {
return; // No webhook configured, or this event type isn't enabled for this place.
}
const embed = {
title: config.title,
color: config.color,
fields: buildFields(fields),
footer: { text: place.name || place.id },
timestamp: new Date().toISOString()
};
const payload = { embeds: [embed] };
if (place.webhookContent) {
payload.content = String(place.webhookContent);
}
const result = await postToDiscord(place.webhookUrl, payload);
if (!result.ok) {
console.error(`Discord webhook for place ${place.id} failed: ${result.message}`);
}
}
// Sends a one-off test embed straight to `webhookUrl` (bypassing the place's toggles, since the
// user is explicitly asking to test it), optionally including `content`. Used by the dashboard's
// "Send test" button. Returns `{ ok, message }` so the caller can surface any failure to the user.
async function sendTestWebhook(webhookUrl, content) {
if (!webhookUrl) {
return { ok: false, message: 'No webhook URL configured' };
}
const payload = {
embeds: [{
title: '🔔 Test notification',
description: 'This is a test alert from NOTXCS. If you can see this, your webhook is configured correctly.',
color: COLORS.BLUE,
timestamp: new Date().toISOString()
}]
};
if (content) {
payload.content = String(content);
}
return postToDiscord(webhookUrl, payload);
}
module.exports = { sendPlaceWebhook, sendTestWebhook, EVENT_CONFIG };