update: integrate discord webhook

This commit is contained in:
Nathan FONTEYNE 2026-07-16 12:00:30 +02:00
parent 0b4abe5b80
commit 60d20e764d
6 changed files with 229 additions and 0 deletions

View file

@ -56,6 +56,68 @@ function feedRowTemplate(feed) {
`; `;
} }
// Discord webhook URLs carry a secret token — mask it in the list view like
// maskIcsUrl already does for ICS feed URLs.
function maskDiscordUrl(url) {
try {
const u = new URL(url);
const parts = u.pathname.split('/').filter(Boolean);
const webhookId = parts[parts.length - 2];
return `${u.hostname}/…/${webhookId}/••••`;
} catch {
return '••••';
}
}
function discordWebhookRowTemplate(hook) {
return `
<div class="feed-row" data-webhook-id="${hook.id}">
<span class="feed-label">${hook.label ? `${escapeHtml(hook.label)}` : ''}${escapeHtml(maskDiscordUrl(hook.url))}</span>
<button type="button" class="secondary icon-btn remove-discord-webhook-btn" data-webhook-id="${hook.id}">Supprimer</button>
</div>
`;
}
async function loadDiscordWebhooks() {
const webhooks = await api.get('/api/admin/discord-webhooks');
const container = document.getElementById('discord-webhooks-list');
container.innerHTML = webhooks.length
? webhooks.map(discordWebhookRowTemplate).join('')
: '<p class="empty">Aucun webhook configuré.</p>';
container.querySelectorAll('.remove-discord-webhook-btn').forEach((btn) => {
btn.addEventListener('click', () => onRemoveDiscordWebhook(parseInt(btn.dataset.webhookId, 10)));
});
}
async function onAddDiscordWebhook(e) {
e.preventDefault();
const form = e.target;
const label = form.label.value.trim();
const url = form.url.value.trim();
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = 'Vérification…';
try {
await api.post('/api/admin/discord-webhooks', { label, url });
form.reset();
await loadDiscordWebhooks();
} catch (err) {
showError(err.message);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = '+ Ajouter';
}
}
async function onRemoveDiscordWebhook(id) {
try {
await api.del(`/api/admin/discord-webhooks/${id}`);
await loadDiscordWebhooks();
} catch (err) {
showError(err.message);
}
}
function calendarUserRowTemplate(user) { function calendarUserRowTemplate(user) {
return ` return `
<div class="admin-user-row calendar-person-row" data-user-id="${user.id}"> <div class="admin-user-row calendar-person-row" data-user-id="${user.id}">
@ -221,10 +283,26 @@ async function onSaveSlotSettings(e) {
<div class="panel admin-user-list" id="calendar-people-list"> <div class="panel admin-user-list" id="calendar-people-list">
<p class="empty">Chargement</p> <p class="empty">Chargement</p>
</div> </div>
<h2>Notifications Discord</h2>
<p class="note">
Chaque webhook reçoit un message quand une répétition est proposée, retirée, acceptée ou refusée.
Créer un webhook : sur Discord, Paramètres du salon Intégrations Webhooks Nouveau webhook Copier l'URL.
</p>
<div class="panel" id="discord-webhooks-list">
<p class="empty">Chargement</p>
</div>
<form id="add-discord-webhook-form" class="inline-form">
<input name="label" placeholder="Libellé (optionnel)">
<input name="url" type="url" placeholder="URL du webhook Discord" required>
<button type="submit" class="secondary icon-btn">+ Ajouter</button>
</form>
`; `;
document.getElementById('slot-settings-form').addEventListener('submit', onSaveSlotSettings); document.getElementById('slot-settings-form').addEventListener('submit', onSaveSlotSettings);
document.getElementById('add-discord-webhook-form').addEventListener('submit', onAddDiscordWebhook);
await loadSlotSettingsForm(); await loadSlotSettingsForm();
await loadCalendarUsers(); await loadCalendarUsers();
await loadDiscordWebhooks();
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }

View file

@ -0,0 +1,6 @@
CREATE TABLE discord_webhooks (
id SERIAL PRIMARY KEY,
label TEXT,
url TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

79
src/lib/discord.js Normal file
View file

@ -0,0 +1,79 @@
const discordWebhooksRepo = require('../repositories/discordWebhooksRepo');
const WEBHOOK_URL_RE = /^https:\/\/(discord\.com|discordapp\.com)\/api\/webhooks\/\d+\/[\w-]+$/;
function isValidWebhookUrl(url) {
return WEBHOOK_URL_RE.test(url);
}
// Confirms the URL actually points at a live Discord webhook without
// posting a visible message — Discord's webhook endpoint responds to GET
// with the webhook's own metadata.
async function checkWebhookReachable(url) {
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
return res.ok;
}
function formatDateTime(iso) {
return new Date(iso).toLocaleString('fr-FR', {
timeZone: 'Europe/Paris',
weekday: 'long',
day: 'numeric',
month: 'long',
hour: '2-digit',
minute: '2-digit',
});
}
// Fire-and-forget: sends `content` to every registered webhook, in parallel,
// each with its own timeout. Never throws — a Discord/network failure must
// never break the rehearsal action that triggered the notification.
async function notifyAll(content) {
let webhooks;
try {
webhooks = await discordWebhooksRepo.findAll();
} catch (err) {
console.error('[discord] could not load webhooks:', err.message);
return;
}
if (!webhooks.length) return;
const results = await Promise.allSettled(
webhooks.map((w) =>
fetch(w.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
signal: AbortSignal.timeout(5000),
})
)
);
results.forEach((result, i) => {
if (result.status === 'rejected') {
console.error(`[discord] webhook ${webhooks[i].id} failed:`, result.reason.message);
}
});
}
function rehearsalProposedMessage({ userName, startsAt, location }) {
return `🗓️ **${userName}** a proposé une répétition le ${formatDateTime(startsAt)}${location ? ` à ${location}` : ''}.`;
}
function rehearsalRemovedMessage({ userName, startsAt }) {
return `🗑️ **${userName}** a retiré la répétition du ${formatDateTime(startsAt)}.`;
}
function rehearsalVoteMessage({ userName, vote, startsAt }) {
const verb = vote === 'accept' ? 'accepté' : 'refusé';
const emoji = vote === 'accept' ? '✔' : '✘';
return `${emoji} **${userName}** a ${verb} la répétition du ${formatDateTime(startsAt)}.`;
}
module.exports = {
isValidWebhookUrl,
checkWebhookReachable,
notifyAll,
rehearsalProposedMessage,
rehearsalRemovedMessage,
rehearsalVoteMessage,
};

View file

@ -0,0 +1,20 @@
const pool = require('../db/pool');
async function findAll() {
const { rows } = await pool.query('SELECT id, label, url, created_at FROM discord_webhooks ORDER BY created_at');
return rows;
}
async function create({ label, url }) {
const { rows } = await pool.query(
'INSERT INTO discord_webhooks (label, url) VALUES ($1, $2) RETURNING id, label, url, created_at',
[label || null, url]
);
return rows[0];
}
async function remove(id) {
await pool.query('DELETE FROM discord_webhooks WHERE id = $1', [id]);
}
module.exports = { findAll, create, remove };

View file

@ -3,6 +3,8 @@ const usersRepo = require('../repositories/usersRepo');
const songsRepo = require('../repositories/songsRepo'); const songsRepo = require('../repositories/songsRepo');
const suggestionsRepo = require('../repositories/suggestionsRepo'); const suggestionsRepo = require('../repositories/suggestionsRepo');
const setlistsRepo = require('../repositories/setlistsRepo'); const setlistsRepo = require('../repositories/setlistsRepo');
const discordWebhooksRepo = require('../repositories/discordWebhooksRepo');
const discord = require('../lib/discord');
const { requireAdmin } = require('../auth/middleware'); const { requireAdmin } = require('../auth/middleware');
const asyncHandler = require('../lib/asyncHandler'); const asyncHandler = require('../lib/asyncHandler');
@ -53,4 +55,42 @@ router.get(
}) })
); );
router.get(
'/discord-webhooks',
requireAdmin,
asyncHandler(async (req, res) => {
const webhooks = await discordWebhooksRepo.findAll();
res.json(webhooks.map((w) => ({ id: w.id, label: w.label, url: w.url, createdAt: w.created_at })));
})
);
router.post(
'/discord-webhooks',
requireAdmin,
asyncHandler(async (req, res) => {
const { label, url } = req.body || {};
if (!url || !discord.isValidWebhookUrl(url.trim())) {
return res.status(400).json({ error: 'invalid_webhook_url' });
}
const trimmedUrl = url.trim();
try {
const reachable = await discord.checkWebhookReachable(trimmedUrl);
if (!reachable) return res.status(400).json({ error: 'webhook_unreachable' });
} catch (err) {
return res.status(400).json({ error: 'webhook_unreachable', message: err.message });
}
const webhook = await discordWebhooksRepo.create({ label: label ? label.trim() : null, url: trimmedUrl });
res.status(201).json({ id: webhook.id, label: webhook.label, url: webhook.url, createdAt: webhook.created_at });
})
);
router.delete(
'/discord-webhooks/:id',
requireAdmin,
asyncHandler(async (req, res) => {
await discordWebhooksRepo.remove(parseInt(req.params.id, 10));
res.status(204).end();
})
);
module.exports = router; module.exports = router;

View file

@ -1,5 +1,6 @@
const express = require('express'); const express = require('express');
const rehearsalsRepo = require('../repositories/rehearsalsRepo'); const rehearsalsRepo = require('../repositories/rehearsalsRepo');
const discord = require('../lib/discord');
const asyncHandler = require('../lib/asyncHandler'); const asyncHandler = require('../lib/asyncHandler');
const router = express.Router(); const router = express.Router();
@ -46,6 +47,9 @@ router.post(
location: location ? location.trim() : null, location: location ? location.trim() : null,
proposedBy: req.user.id, proposedBy: req.user.id,
}); });
discord.notifyAll(
discord.rehearsalProposedMessage({ userName: req.user.name, startsAt: rehearsal.starts_at, location: rehearsal.location })
);
res.status(201).json({ res.status(201).json({
id: rehearsal.id, id: rehearsal.id,
startsAt: rehearsal.starts_at, startsAt: rehearsal.starts_at,
@ -65,6 +69,7 @@ router.delete(
return res.status(403).json({ error: 'forbidden' }); return res.status(403).json({ error: 'forbidden' });
} }
await rehearsalsRepo.remove(req.params.id); await rehearsalsRepo.remove(req.params.id);
discord.notifyAll(discord.rehearsalRemovedMessage({ userName: req.user.name, startsAt: rehearsal.starts_at }));
res.status(204).end(); res.status(204).end();
}) })
); );
@ -79,6 +84,7 @@ router.post(
const rehearsal = await rehearsalsRepo.findById(req.params.id); const rehearsal = await rehearsalsRepo.findById(req.params.id);
if (!rehearsal) return res.status(404).json({ error: 'not_found' }); if (!rehearsal) return res.status(404).json({ error: 'not_found' });
const saved = await rehearsalsRepo.upsertVote(req.params.id, req.user.id, vote); const saved = await rehearsalsRepo.upsertVote(req.params.id, req.user.id, vote);
discord.notifyAll(discord.rehearsalVoteMessage({ userName: req.user.name, vote, startsAt: rehearsal.starts_at }));
res.json({ res.json({
id: saved.id, id: saved.id,
rehearsalId: saved.rehearsal_id, rehearsalId: saved.rehearsal_id,