mirror of
https://github.com/nfonteyne/octane-website.git
synced 2026-09-03 23:24:48 +02:00
update: integrate discord webhook
This commit is contained in:
parent
0b4abe5b80
commit
60d20e764d
6 changed files with 229 additions and 0 deletions
|
|
@ -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) {
|
||||
return `
|
||||
<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">
|
||||
<p class="empty">Chargement…</p>
|
||||
</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('add-discord-webhook-form').addEventListener('submit', onAddDiscordWebhook);
|
||||
await loadSlotSettingsForm();
|
||||
await loadCalendarUsers();
|
||||
await loadDiscordWebhooks();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
|
|
|
|||
6
src/db/migrations/016_discord_webhooks.sql
Normal file
6
src/db/migrations/016_discord_webhooks.sql
Normal 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
79
src/lib/discord.js
Normal 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,
|
||||
};
|
||||
20
src/repositories/discordWebhooksRepo.js
Normal file
20
src/repositories/discordWebhooksRepo.js
Normal 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 };
|
||||
|
|
@ -3,6 +3,8 @@ const usersRepo = require('../repositories/usersRepo');
|
|||
const songsRepo = require('../repositories/songsRepo');
|
||||
const suggestionsRepo = require('../repositories/suggestionsRepo');
|
||||
const setlistsRepo = require('../repositories/setlistsRepo');
|
||||
const discordWebhooksRepo = require('../repositories/discordWebhooksRepo');
|
||||
const discord = require('../lib/discord');
|
||||
const { requireAdmin } = require('../auth/middleware');
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
const express = require('express');
|
||||
const rehearsalsRepo = require('../repositories/rehearsalsRepo');
|
||||
const discord = require('../lib/discord');
|
||||
const asyncHandler = require('../lib/asyncHandler');
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -46,6 +47,9 @@ router.post(
|
|||
location: location ? location.trim() : null,
|
||||
proposedBy: req.user.id,
|
||||
});
|
||||
discord.notifyAll(
|
||||
discord.rehearsalProposedMessage({ userName: req.user.name, startsAt: rehearsal.starts_at, location: rehearsal.location })
|
||||
);
|
||||
res.status(201).json({
|
||||
id: rehearsal.id,
|
||||
startsAt: rehearsal.starts_at,
|
||||
|
|
@ -65,6 +69,7 @@ router.delete(
|
|||
return res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
await rehearsalsRepo.remove(req.params.id);
|
||||
discord.notifyAll(discord.rehearsalRemovedMessage({ userName: req.user.name, startsAt: rehearsal.starts_at }));
|
||||
res.status(204).end();
|
||||
})
|
||||
);
|
||||
|
|
@ -79,6 +84,7 @@ router.post(
|
|||
const rehearsal = await rehearsalsRepo.findById(req.params.id);
|
||||
if (!rehearsal) return res.status(404).json({ error: 'not_found' });
|
||||
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({
|
||||
id: saved.id,
|
||||
rehearsalId: saved.rehearsal_id,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue