// 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 };