146 lines
5 KiB
JavaScript
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 };
|