diff --git a/config-example.yml b/config-example.yml index 0d193e2..932aecf 100644 --- a/config-example.yml +++ b/config-example.yml @@ -30,20 +30,33 @@ services: timeout_seconds: 5 info: - label: "Commits totaux" - source: "rss_multi" - urls: + source: "gitea_multi" + base_url: "" + owner: "" + repos: - "" - path: "rss.channel.item" aggregate: "count" + token_env: "GITEA_TOKEN" + fallback_urls: + - "" - label: "Dernier commit" - source: "rss_multi" - urls: + source: "gitea_multi" + base_url: "" + owner: "" + repos: - "" - path: "rss.channel.item" aggregate: "latest_title" - - label: "Publié le" - source: "rss_multi" - urls: + token_env: "GITEA_TOKEN" + fallback_urls: + - "" + - label: "Publié le" + source: "gitea_multi" + base_url: "" + owner: "" + repos: - "" - path: "rss.channel.item" aggregate: "latest_pubdate" + token_env: "GITEA_TOKEN" + transform: "discord_time" + fallback_urls: + - "" diff --git a/src/bot.js b/src/bot.js index c9823b9..2959c7b 100644 --- a/src/bot.js +++ b/src/bot.js @@ -1,14 +1,25 @@ -require('dotenv').config(); +const path = require('path'); +const dotenv = require('dotenv'); const { Client, GatewayIntentBits, EmbedBuilder } = require('discord.js'); const { fetch } = require('undici'); const fs = require('fs'); const yaml = require('js-yaml'); +const envPath = path.resolve(__dirname, '..', '.env'); +const envResult = dotenv.config({ path: envPath }); +if (envResult.error) { + // Fallback to default resolution when the project root already matches process.cwd(). + dotenv.config(); +} + const DISCORD_TOKEN = process.env.DISCORD_TOKEN; const STATUS_GUILD_ID = process.env.STATUS_GUILD_ID; const STATUS_CHANNEL_ID = process.env.STATUS_CHANNEL_ID; const ADMIN_USER_ID = process.env.ADMIN_USER_ID; const CONFIG_PATH = process.env.CONFIG_PATH || './config.yml'; +const RESOLVED_CONFIG_PATH = path.isAbsolute(CONFIG_PATH) + ? CONFIG_PATH + : path.resolve(__dirname, '..', CONFIG_PATH); const REFRESH_INTERVAL = parseInt(process.env.REFRESH_INTERVAL || '60', 10); const STICKY_MESSAGE = String(process.env.STICKY_MESSAGE || 'true').toLowerCase() === 'true'; @@ -27,7 +38,7 @@ for (const [name, value] of Object.entries(requiredEnv)) { } function loadConfig() { - const text = fs.readFileSync(CONFIG_PATH, 'utf8'); + const text = fs.readFileSync(RESOLVED_CONFIG_PATH, 'utf8'); return yaml.load(text); } @@ -99,29 +110,59 @@ function extractTag(text, tag) { return match ? decodeXmlEntities(match[1].trim()) : null; } +function extractAtomLink(text) { + if (!text || typeof text !== 'string') return null; + const match = text.match(/]*href=['"]([^'"]+)['"][^>]*>/i); + if (match && match[1]) return decodeXmlEntities(match[1].trim()); + return extractTag(text, 'link'); +} + function parseRss(text) { - const channelMatch = /([\s\S]*?)<\/channel>/i.exec(text); - const channelText = channelMatch ? channelMatch[1] : text; + const channelMatch = /]*>([\s\S]*?)<\/channel>/i.exec(text); + const feedMatch = /]*>([\s\S]*?)<\/feed>/i.exec(text); + const channelText = channelMatch ? channelMatch[1] : (feedMatch ? feedMatch[1] : text); const items = []; - const itemRegex = /([\s\S]*?)<\/item>/gi; + const itemRegex = /]*>([\s\S]*?)<\/item>/gi; let itemMatch; while ((itemMatch = itemRegex.exec(channelText))) { const item = itemMatch[1]; items.push({ title: extractTag(item, 'title'), link: extractTag(item, 'link'), - description: extractTag(item, 'description'), + description: extractTag(item, 'description') || extractTag(item, 'content:encoded'), author: extractTag(item, 'author'), - pubDate: extractTag(item, 'pubDate'), - guid: extractTag(item, 'guid'), + pubDate: extractTag(item, 'pubDate') || extractTag(item, 'published') || extractTag(item, 'updated'), + guid: extractTag(item, 'guid') || extractTag(item, 'id'), + updated: extractTag(item, 'updated'), + published: extractTag(item, 'published'), }); } + + if (!items.length && feedMatch) { + const entryRegex = /]*>([\s\S]*?)<\/entry>/gi; + let entryMatch; + while ((entryMatch = entryRegex.exec(feedMatch[1]))) { + const entry = entryMatch[1]; + const fullEntry = entryMatch[0]; + items.push({ + title: extractTag(entry, 'title'), + link: extractAtomLink(fullEntry), + description: extractTag(entry, 'summary') || extractTag(entry, 'content'), + author: extractTag(entry, 'author'), + pubDate: extractTag(entry, 'published') || extractTag(entry, 'updated'), + guid: extractTag(entry, 'id'), + updated: extractTag(entry, 'updated'), + published: extractTag(entry, 'published'), + }); + } + } + return { rss: { channel: { title: extractTag(channelText, 'title'), - link: extractTag(channelText, 'link'), - description: extractTag(channelText, 'description'), + link: extractAtomLink(channelMatch ? channelMatch[0] : (feedMatch ? feedMatch[0] : channelText)), + description: extractTag(channelText, 'description') || extractTag(channelText, 'subtitle'), item: items, }, }, @@ -137,6 +178,12 @@ function normalizeRssItem(item) { const out = { ...item }; if (out.title) out.title = stripHtml(out.title); if (out.pubDate) out.pubDate = stripHtml(out.pubDate); + if (!out.pubDate) { + if (out.published) out.pubDate = stripHtml(out.published); + else if (out.updated) out.pubDate = stripHtml(out.updated); + } + if (out.published) out.published = stripHtml(out.published); + if (out.updated) out.updated = stripHtml(out.updated); return out; } return null; @@ -148,7 +195,11 @@ async function fetchRssWithTimeout(url, timeoutMs) { try { const resp = await fetch(url, { signal: ctrl.signal }); clearTimeout(timer); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + if (!resp.ok) { + const err = new Error(`HTTP ${resp.status}`); + err.status = resp.status; + throw err; + } const text = await resp.text(); return parseRss(text); } finally { @@ -156,6 +207,250 @@ async function fetchRssWithTimeout(url, timeoutMs) { } } +async function gatherRssItems(urls, path, dataCache, timeoutMs) { + const items = []; + for (const url of urls) { + if (!url) continue; + let cached = dataCache.get(`rss:${url}`); + if (!cached) { + cached = await fetchRssWithTimeout(url, timeoutMs); + dataCache.set(`rss:${url}`, cached); + } + const rssPath = path || 'rss.channel.item'; + const pathVal = getValueAtPath(cached, rssPath); + if (Array.isArray(pathVal)) { + for (const entry of pathVal) { + const norm = normalizeRssItem(entry); + if (norm) items.push(norm); + } + } else { + const norm = normalizeRssItem(pathVal); + if (norm) items.push(norm); + } + } + return items; +} + +function aggregateRssItems(items, aggregate) { + if (aggregate === 'count') { + return items.length; + } + + if (aggregate === 'latest_title' || aggregate === 'latest_pubdate') { + let latest = null; + for (const entry of items) { + const date = entry && entry.pubDate ? new Date(entry.pubDate) : null; + const timestamp = date && !Number.isNaN(date.getTime()) ? date.getTime() : null; + if (!latest || (timestamp !== null && timestamp > latest.timestamp)) { + latest = { entry, timestamp }; + } + } + if (!latest) return undefined; + if (aggregate === 'latest_title') { + return latest.entry.title || undefined; + } + if (latest.timestamp) { + return new Date(latest.timestamp).toISOString(); + } + return latest.entry.pubDate || undefined; + } + + return items.map(entry => entry.title).filter(Boolean).join(', '); +} + +function extractFirstCommitMessageFromDescription(description) { + if (!description || typeof description !== 'string') return null; + const liMatch = description.match(/]*>([\s\S]*?)<\/li>/i); + if (!liMatch) return null; + const liContent = liMatch[1]; + const anchorMatch = liContent.match(/]*>([\s\S]*?)<\/a>/i); + const text = anchorMatch ? anchorMatch[1] : liContent; + const cleaned = stripHtml(text).replace(/\s+/g, ' ').trim(); + return cleaned || null; +} + +function buildCommitInfo(prefix, branch, commit) { + if (!commit) return null; + const message = (commit.summary || commit.message || '').trim(); + const title = message || prefix; + let location = prefix || ''; + if (branch) { + location = location ? `${location} (${branch})` : branch; + } + const detailParts = []; + if (location) detailParts.push(location); + if (commit.author) detailParts.push(`par ${commit.author}`); + const details = detailParts.join(' — '); + return { title, details }; +} + +function computeGiteaFallbackAggregate(items, aggregate) { + if (aggregate === 'count') return items.length; + + let latest = null; + for (const entry of items) { + if (!entry) continue; + const date = entry.pubDate ? new Date(entry.pubDate) : null; + const timestamp = date && !Number.isNaN(date.getTime()) ? date.getTime() : null; + if (!latest || (timestamp !== null && timestamp > latest.timestamp)) { + latest = { entry, timestamp }; + } + } + + if (!latest) return undefined; + + if (aggregate === 'latest_pubdate') { + if (latest.timestamp) { + return new Date(latest.timestamp).toISOString(); + } + return latest.entry.pubDate || undefined; + } + + if (aggregate === 'latest_title') { + const title = stripHtml(latest.entry.title || ''); + const message = extractFirstCommitMessageFromDescription(latest.entry.description) || ''; + if (title || message) { + const primary = message || title; + const secondary = title && title !== primary ? title : (message && message !== primary ? message : ''); + const info = { title: primary }; + if (secondary) info.details = secondary; + return info; + } + return undefined; + } + + return items.map(entry => entry.title).filter(Boolean).join(', '); +} + +function normalizeGiteaRepos(repos, defaultOwner) { + if (!repos) return []; + const list = Array.isArray(repos) ? repos : [repos]; + const out = []; + for (const entry of list) { + if (!entry) continue; + if (typeof entry === 'string') { + const trimmed = entry.trim(); + if (!trimmed) continue; + if (trimmed.includes('/')) { + const [owner, repo] = trimmed.split('/', 2); + if (owner && repo) { + out.push({ + owner, + repo, + branch: null, + displayName: `${owner}/${repo}`, + }); + } + } else if (defaultOwner) { + out.push({ + owner: defaultOwner, + repo: trimmed, + branch: null, + displayName: `${defaultOwner}/${trimmed}`, + }); + } + } else if (typeof entry === 'object') { + const owner = entry.owner || defaultOwner; + const repo = entry.repo; + if (!owner || !repo) continue; + out.push({ + owner, + repo, + branch: entry.branch || null, + displayName: entry.display_name || entry.displayName || `${owner}/${repo}`, + }); + } + } + return out; +} + +function resolveGiteaToken(info) { + if (info && typeof info === 'object') { + if (info.token) return info.token; + if (info.token_env && process.env[info.token_env]) return process.env[info.token_env]; + } + return process.env.GITEA_TOKEN || process.env.GITEA_ACCESS_TOKEN || null; +} + +function shouldUseGiteaFallback(error) { + const status = error && typeof error === 'object' ? error.status : null; + return status === 401 || status === 403 || status === 404; +} + +function pickGiteaCommitTimestamp(commit) { + const candidates = [ + commit?.commit?.author?.date, + commit?.commit?.committer?.date, + commit?.author?.date, + commit?.committer?.date, + ]; + for (const candidate of candidates) { + if (!candidate) continue; + const ts = Date.parse(candidate); + if (!Number.isNaN(ts)) return ts; + } + return null; +} + +async function fetchGiteaRepoSummary({ baseUrl, owner, repo, branch, token, timeoutMs }) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const url = new URL(`/api/v1/repos/${owner}/${repo}/commits`, baseUrl); + url.searchParams.set('limit', '1'); + if (branch) url.searchParams.set('sha', branch); + + const headers = {}; + if (token) headers.Authorization = `token ${token}`; + + const resp = await fetch(url, { signal: ctrl.signal, headers }); + clearTimeout(timer); + if (!resp.ok) { + const err = new Error(`HTTP ${resp.status}`); + err.status = resp.status; + throw err; + } + + const text = await resp.text(); + let commits = []; + if (text.trim()) { + try { + commits = JSON.parse(text); + } catch { + commits = []; + } + } + + const totalHeader = resp.headers.get('x-total-count'); + const totalCommits = totalHeader && /^\d+$/.test(totalHeader) ? Number(totalHeader) : null; + + const latestCommitRaw = Array.isArray(commits) && commits.length ? commits[0] : null; + const latestCommit = latestCommitRaw + ? { + sha: latestCommitRaw.sha || latestCommitRaw.id || null, + message: latestCommitRaw.commit?.message || latestCommitRaw.message || '', + summary: (() => { + const msg = latestCommitRaw.commit?.message || latestCommitRaw.message || ''; + return msg ? msg.split(/\r?\n/)[0].trim() : ''; + })(), + author: + latestCommitRaw.commit?.author?.name || + latestCommitRaw.author?.login || + latestCommitRaw.author?.username || + latestCommitRaw.author?.name || + latestCommitRaw.commit?.committer?.name || + null, + url: latestCommitRaw.html_url || latestCommitRaw.url || latestCommitRaw.commit?.url || null, + timestamp: pickGiteaCommitTimestamp(latestCommitRaw), + } + : null; + + return { totalCommits, latestCommit }; + } finally { + clearTimeout(timer); + } +} + function applyInfoTransform(info, value) { if (value === undefined || value === null) return value; if (!info || typeof info !== 'object') return value; @@ -232,51 +527,93 @@ async function collectExtras(svc, context) { case 'rss_multi': { const urls = Array.isArray(info.urls) && info.urls.length ? info.urls : (info.url ? [info.url] : []); if (!urls.length) throw new Error('URLs manquantes pour rss_multi'); - const items = []; - for (const url of urls) { - let cached = dataCache.get(`rss:${url}`); + const items = await gatherRssItems(urls, info.path, dataCache, context.timeoutMs); + value = aggregateRssItems(items, info.aggregate); + break; + } + case 'gitea_multi': { + const baseUrlRaw = info.base_url || svc.health_url || ''; + if (!baseUrlRaw) throw new Error('URL manquante pour gitea_multi'); + const baseUrl = baseUrlRaw.replace(/\/+$/, ''); + const repoEntries = normalizeGiteaRepos(info.repos, info.owner); + if (!repoEntries.length) throw new Error('Dépôts manquants pour gitea_multi'); + + const authToken = resolveGiteaToken(info); + const summaries = []; + let fetchError = null; + for (const repoEntry of repoEntries) { + const cacheKey = `gitea:${baseUrl}:${repoEntry.owner}/${repoEntry.repo}:${repoEntry.branch || ''}`; + let cached = dataCache.get(cacheKey); if (!cached) { - cached = await fetchRssWithTimeout(url, context.timeoutMs); - dataCache.set(`rss:${url}`, cached); - } - const rssPath = info.path || 'rss.channel.item'; - const pathVal = getValueAtPath(cached, rssPath); - if (Array.isArray(pathVal)) { - for (const entry of pathVal) { - const norm = normalizeRssItem(entry); - if (norm) items.push(norm); + try { + cached = await fetchGiteaRepoSummary({ + baseUrl, + owner: repoEntry.owner, + repo: repoEntry.repo, + branch: repoEntry.branch, + token: authToken, + timeoutMs: context.timeoutMs, + }); + dataCache.set(cacheKey, cached); + } catch (err) { + fetchError = err; + break; } - } else { - const norm = normalizeRssItem(pathVal); - if (norm) items.push(norm); } + summaries.push({ repo: repoEntry, summary: cached }); + } + + if (fetchError) { + const fallbackUrls = Array.isArray(info.fallback_urls) ? info.fallback_urls.filter(Boolean) : []; + if (fallbackUrls.length && shouldUseGiteaFallback(fetchError)) { + const items = await gatherRssItems(fallbackUrls, info.fallback_path || info.path, dataCache, context.timeoutMs); + value = computeGiteaFallbackAggregate(items, info.aggregate); + break; + } + throw fetchError; } if (info.aggregate === 'count') { - value = items.length; + let total = 0; + for (const { summary } of summaries) { + if (summary.totalCommits !== null && summary.totalCommits !== undefined) { + total += summary.totalCommits; + } else { + total = null; + break; + } + } + value = total !== null ? total : undefined; } else if (info.aggregate === 'latest_title' || info.aggregate === 'latest_pubdate') { let latest = null; - for (const entry of items) { - const date = entry.pubDate ? new Date(entry.pubDate) : null; - const timestamp = date && !Number.isNaN(date.getTime()) ? date.getTime() : null; - if (!latest || (timestamp !== null && timestamp > latest.timestamp)) { - latest = { entry, timestamp }; + for (const { repo, summary } of summaries) { + const commit = summary.latestCommit; + if (!commit) continue; + const timestamp = commit.timestamp; + if (timestamp === null || timestamp === undefined) continue; + if (!latest || timestamp > latest.timestamp) { + latest = { repo, commit, timestamp }; } } if (!latest) { value = undefined; } else if (info.aggregate === 'latest_title') { - value = latest.entry.title || undefined; + const prefix = latest.repo.displayName || `${latest.repo.owner}/${latest.repo.repo}`; + value = buildCommitInfo(prefix, latest.repo.branch || null, latest.commit); } else { - if (latest.timestamp) { - const unixSeconds = Math.floor(latest.timestamp / 1000); - value = ``; - } else { - value = undefined; - } + value = new Date(latest.timestamp).toISOString(); } } else { - value = items.map(entry => entry.title).filter(Boolean).join(', '); + value = summaries + .map(({ repo, summary }) => { + const message = summary.latestCommit?.message; + if (!message) return null; + const firstLine = stripHtml(message.split(/\r?\n/)[0]); + const prefix = repo.displayName || `${repo.owner}/${repo.repo}`; + return `${prefix}: ${firstLine}`; + }) + .filter(Boolean) + .join(', '); } break; } @@ -314,8 +651,15 @@ const metricNameOverrides = { ping_ms: 'Ping', server_ping_ms: 'Ping', mare_ping_ms: 'Ping', + mare_authorized_connections_peak: 'Pic de connexions', + mare_authorized_connections_highwater: 'Pic de connexions', + mare_authorized_connections_max: 'Pic de connexions', + mare_authorized_connections_record: 'Pic de connexions', + _peak_authorized_connections: 'Pic de connexions', }; +const connectionPeaks = new Map(); + function pickKeyMetrics(name, all) { const perService = { 'Serveur de synchronisation': [ @@ -350,7 +694,41 @@ function choosePingMetric(allMetrics) { return null; } -function formatMetrics(name, allMetrics, metricsByService) { +function asFiniteNumber(value) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return null; + const num = Number(trimmed); + if (Number.isFinite(num)) return num; + } + return null; +} + +function pickPeakMetric(primary, fallback) { + const candidates = [ + 'mare_authorized_connections_peak', + 'mare_authorized_connections_highwater', + 'mare_authorized_connections_max', + 'mare_authorized_connections_record', + ]; + + for (const key of candidates) { + if (primary && Object.prototype.hasOwnProperty.call(primary, key)) { + const val = asFiniteNumber(primary[key]); + if (val !== null) return { key, value: val }; + } + } + for (const key of candidates) { + if (fallback && Object.prototype.hasOwnProperty.call(fallback, key)) { + const val = asFiniteNumber(fallback[key]); + if (val !== null) return { key, value: val }; + } + } + return null; +} + +function formatMetrics(name, allMetrics, metricsByService, peakStore = null) { const selected = pickKeyMetrics(name, allMetrics); const entries = []; const skipKeys = new Set(['ping_ms', 'server_ping_ms', 'mare_ping_ms']); @@ -359,16 +737,37 @@ function formatMetrics(name, allMetrics, metricsByService) { const fallbackMetrics = fallbackName ? (metricsByService[fallbackName] || {}) : {}; if (name === "Serveur d'identification") { - const usersConnected = allMetrics.mare_authorized_connections ?? fallbackMetrics.mare_authorized_connections; - const usersRegistered = allMetrics.mare_users_registered ?? fallbackMetrics.mare_users_registered; + const usersConnectedRaw = allMetrics.mare_authorized_connections ?? fallbackMetrics.mare_authorized_connections; + const usersRegisteredRaw = allMetrics.mare_users_registered ?? fallbackMetrics.mare_users_registered; + + entries.push([ + 'mare_users_registered', + usersRegisteredRaw !== undefined ? usersRegisteredRaw : 'N/A', + ]); + + let peakEntry = pickPeakMetric(allMetrics, fallbackMetrics); + + if (peakStore) { + if (peakEntry) { + peakStore.set(name, peakEntry.value); + } else { + const numericConnected = asFiniteNumber(usersConnectedRaw); + if (numericConnected !== null) { + const prev = peakStore.get(name); + const nextPeak = prev !== undefined ? Math.max(prev, numericConnected) : numericConnected; + peakStore.set(name, nextPeak); + peakEntry = { key: '_peak_authorized_connections', value: nextPeak }; + } + } + } + + if (peakEntry) { + entries.push([peakEntry.key, peakEntry.value]); + } entries.push([ 'mare_authorized_connections', - usersConnected !== undefined ? usersConnected : 'N/A', - ]); - entries.push([ - 'mare_users_registered', - usersRegistered !== undefined ? usersRegistered : 'N/A', + usersConnectedRaw !== undefined ? usersConnectedRaw : 'N/A', ]); } else { for (const key of Object.keys(selected)) { @@ -427,7 +826,22 @@ function formatExtraValue(value) { return value.map(v => formatExtraValue(v)).join(', '); } if (value && typeof value === 'object') { - return Object.values(value).map(v => formatExtraValue(v)).join(', '); + const hasTitle = Object.prototype.hasOwnProperty.call(value, 'title'); + const hasDetails = Object.prototype.hasOwnProperty.call(value, 'details'); + if (hasTitle || hasDetails) { + const parts = []; + if (hasTitle && value.title !== undefined && value.title !== null && value.title !== '') { + parts.push(formatExtraValue(value.title)); + } + if (hasDetails && value.details !== undefined && value.details !== null && value.details !== '') { + parts.push(formatExtraValue(value.details)); + } + return parts.join(' — '); + } + return Object.values(value) + .filter(v => v !== undefined && v !== null && v !== '') + .map(v => formatExtraValue(v)) + .join(', '); } if (typeof value === 'string') { value = stripHtml(value); @@ -576,7 +990,7 @@ function buildEmbed(results) { const m = { ...(status.metrics || {}) }; if (typeof status.ping_ms === 'number') m.ping_ms = status.ping_ms; - const entries = formatMetrics(name, m, metricsByService).slice(0, 6); + const entries = formatMetrics(name, m, metricsByService, connectionPeaks).slice(0, 6); if (entries.length) { const pretty = entries.map(([k, v]) => `- ${prettyMetricName(k)}: ${prettyMetricValue(k, v)}`).join('\n'); value += `\n${pretty}`;