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'; const requiredEnv = { DISCORD_TOKEN, STATUS_GUILD_ID, STATUS_CHANNEL_ID, ADMIN_USER_ID, }; for (const [name, value] of Object.entries(requiredEnv)) { if (!value) { console.error(`Missing ${name} in .env`); process.exit(1); } } function loadConfig() { const text = fs.readFileSync(RESOLVED_CONFIG_PATH, 'utf8'); return yaml.load(text); } function parseProm(text) { const out = {}; for (const line of text.split(/\r?\n/)) { if (!line || line.startsWith('#')) continue; const m = line.match(/^([a-zA-Z_:][a-zA-Z0-9_:]*)\s+([0-9eE+\-\.]+)$/); if (m) { const [, name, val] = m; const num = Number(val); if (!Number.isNaN(num)) out[name] = num; } } return out; } function pathToTokens(path) { if (!path) return []; return path .split('.') .flatMap(segment => segment .split(/\[(\d+)\]/) .filter(Boolean) .map(token => (/^\d+$/.test(token) ? Number(token) : token)) ); } function getValueAtPath(obj, path) { if (!path) return obj; const tokens = pathToTokens(path); let current = obj; for (const token of tokens) { if (current === null || current === undefined) return undefined; current = current[token]; } return current; } async function fetchJsonWithTimeout(url, timeoutMs) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), timeoutMs); try { const resp = await fetch(url, { signal: ctrl.signal }); clearTimeout(timer); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const text = await resp.text(); const trimmed = text.trim(); const json = trimmed ? JSON.parse(trimmed) : null; return { json, headers: resp.headers }; } finally { clearTimeout(timer); } } function decodeXmlEntities(str) { if (!str || typeof str !== 'string') return str; return str .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, '&'); } function extractTag(text, tag) { const regex = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i'); const match = regex.exec(text); 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 feedMatch = /]*>([\s\S]*?)<\/feed>/i.exec(text); const channelText = channelMatch ? channelMatch[1] : (feedMatch ? feedMatch[1] : text); const items = []; 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') || extractTag(item, 'content:encoded'), author: extractTag(item, 'author'), 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: extractAtomLink(channelMatch ? channelMatch[0] : (feedMatch ? feedMatch[0] : channelText)), description: extractTag(channelText, 'description') || extractTag(channelText, 'subtitle'), item: items, }, }, }; } function normalizeRssItem(item) { if (!item) return null; if (typeof item === 'string') { return { title: stripHtml(item), pubDate: null }; } if (typeof item === 'object') { 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; } async function fetchRssWithTimeout(url, timeoutMs) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), timeoutMs); try { const resp = await fetch(url, { signal: ctrl.signal }); clearTimeout(timer); 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 { clearTimeout(timer); } } 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; if (info.transform === 'short_sha') { return String(value).slice(0, 7); } if (info.transform === 'discord_time') { const date = new Date(value); if (!Number.isNaN(date.getTime())) { const unixSeconds = Math.floor(date.getTime() / 1000); return ``; } } if (info.transform === 'first_line') { return String(value).split(/\r?\n/)[0]; } if (info.slice && Number.isInteger(info.slice) && info.slice > 0) { return String(value).slice(0, info.slice); } return value; } async function collectExtras(svc, context) { const extras = []; const infoEntries = Array.isArray(svc.info) ? svc.info : []; if (!infoEntries.length) return extras; const dataCache = new Map(); for (const info of infoEntries) { if (!info || typeof info !== 'object') continue; const label = formatExtraLabel(info.label || info.path || info.source || 'Info'); const fallback = info.fallback !== undefined ? info.fallback : 'N/A'; let value; try { switch (info.source) { case 'health_json': value = getValueAtPath(context.healthJson, info.path); break; case 'metrics': value = getValueAtPath(context.metrics, info.path); break; case 'json': { const targetUrl = info.url || svc.health_url || svc.metrics_url; if (!targetUrl) throw new Error('URL manquante pour la source json'); let cached = dataCache.get(`json:${targetUrl}`); if (!cached) { cached = await fetchJsonWithTimeout(targetUrl, context.timeoutMs); dataCache.set(`json:${targetUrl}`, cached); } if (info.header && cached.headers) { const headerVal = cached.headers.get(info.header); if (headerVal !== null) { value = /^\d+$/.test(headerVal) ? Number(headerVal) : headerVal; } } if (value === undefined) { value = getValueAtPath(cached.json, info.path); } break; } case 'rss': { const targetUrl = info.url || svc.health_url; if (!targetUrl) throw new Error('URL manquante pour la source rss'); let cached = dataCache.get(`rss:${targetUrl}`); if (!cached) { cached = await fetchRssWithTimeout(targetUrl, context.timeoutMs); dataCache.set(`rss:${targetUrl}`, cached); } value = getValueAtPath(cached, info.path); break; } 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 = 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) { 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; } } 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') { 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 { 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') { const prefix = latest.repo.displayName || `${latest.repo.owner}/${latest.repo.repo}`; value = buildCommitInfo(prefix, latest.repo.branch || null, latest.commit); } else { value = new Date(latest.timestamp).toISOString(); } } else { 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; } default: value = undefined; } } catch (err) { console.warn(`Info extraction failed for ${label}:`, err); value = fallback; } if (value === undefined || value === null || value === '') { value = fallback; } else { value = applyInfoTransform(info, value); } extras.push({ label, value }); } return extras; } const metricNameOverrides = { initialized_connections: 'Connexions actives', user_push: 'Clients source', user_push_to: 'Clients destinataires', mare_users_registered: 'Comptes enregistrés', mare_pairs: 'Paires actives', mare_groups: 'Syncshells actives', mare_authorized_connections: 'Utilisateurs connectés', accounts_created: 'Comptes créés', mare_files_size: 'Poids total', mare_files: 'Fichiers stockés', 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': [ 'initialized_connections', 'user_push', 'user_push_to', 'mare_pairs', 'mare_groups', ], 'Serveur de fichier': ['mare_files', 'mare_files_size'], "Serveur d'identification": [], }; const selectedKeys = perService[name] || []; const result = {}; for (const key of selectedKeys) { if (key in all) result[key] = all[key]; } if (Object.keys(result).length) return result; return all; } function pickFallbackService(name) { if (name === "Serveur d'identification") return 'Serveur de synchronisation'; return null; } function choosePingMetric(allMetrics) { if ('ping_ms' in allMetrics) return ['ping_ms', allMetrics.ping_ms]; if ('server_ping_ms' in allMetrics) return ['server_ping_ms', allMetrics.server_ping_ms]; if ('mare_ping_ms' in allMetrics) return ['mare_ping_ms', allMetrics.mare_ping_ms]; return null; } 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']); const fallbackName = pickFallbackService(name); const fallbackMetrics = fallbackName ? (metricsByService[fallbackName] || {}) : {}; if (name === "Serveur d'identification") { 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', usersConnectedRaw !== undefined ? usersConnectedRaw : 'N/A', ]); } else { for (const key of Object.keys(selected)) { if (skipKeys.has(key)) continue; entries.push([key, selected[key]]); } } if ('process_start_time_seconds' in allMetrics) { entries.push(['_uptime', computeUptime(allMetrics.process_start_time_seconds)]); } const pingEntry = choosePingMetric(allMetrics) || (fallbackName ? choosePingMetric(fallbackMetrics) : null); if (pingEntry) entries.unshift(pingEntry); return entries.filter(([, v]) => v !== undefined); } function prettyMetricName(key) { if (key === '_uptime') return 'Temps de fonctionnement'; if (key === '_version') return 'Version attendue'; if (metricNameOverrides[key]) return metricNameOverrides[key]; return key.replace(/^mare_(gauge|counter)_/, '').replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); } function prettyMetricValue(key, value) { if (key === '_uptime') return value; if (key === '_version') return String(value); if (typeof value === 'string') return value; if (['ping_ms', 'server_ping_ms', 'mare_ping_ms'].includes(key)) { return `${Math.round(Number(value))} ms`; } const byteKeys = [ 'process_virtual_memory_bytes', 'process_working_set_bytes', 'process_private_memory_bytes', 'files_request_size', 'files_size', 'files_size_cold', 'mare_files_size', ]; if (byteKeys.includes(key) && typeof value === 'number') { return humanFileSize(value); } if (Number.isInteger(value)) return formatNumber(value); return Number(value.toFixed(2)); } function stripHtml(text) { if (typeof text !== 'string') return text; return text.replace(/<(?!t:)[^>]*>/g, '').trim(); } function formatExtraValue(value) { if (Array.isArray(value)) { return value.map(v => formatExtraValue(v)).join(', '); } if (value && typeof value === 'object') { 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); } const formatted = prettyMetricValue('_extra', value); if (typeof formatted === 'number') return formatted.toString(); return formatted; } function formatExtraLabel(label) { if (!label) return ''; switch (label) { case 'rss.channel.item[0].title': return 'Dernier commit'; case 'rss.channel.item[0].pubDate': return 'Publié'; default: return label; } } function humanFileSize(bytes) { if (!Number.isFinite(bytes)) return bytes; const units = ['o', 'Ko', 'Mo', 'Go', 'To', 'Po']; let idx = 0; let val = bytes; while (val >= 1024 && idx < units.length - 1) { val /= 1024; idx += 1; } return `${val.toFixed(val >= 10 || idx === 0 ? 0 : 1)} ${units[idx]}`; } function formatNumber(value) { return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' '); } function computeUptime(startSeconds) { const now = Date.now() / 1000; const diff = Math.max(0, now - Number(startSeconds)); if (diff < 60) return '<1m'; const days = Math.floor(diff / 86400); const hours = Math.floor((diff % 86400) / 3600); const minutes = Math.floor((diff % 3600) / 60); if (days > 0) return `${days}j ${hours}h ${minutes}m`; if (hours > 0) return `${hours}h ${minutes}m`; return `${minutes}m`; } async function checkService(svc) { const res = { healthy: null, http_code: null, error: null, metrics: {}, metrics_error: null, ping_ms: null, extras: [] }; const timeoutMs = Math.max(1000, Math.floor((svc.timeout_seconds || 5) * 1000)); const infoEntries = Array.isArray(svc.info) ? svc.info : []; const wantsHealthJson = infoEntries.some(info => info && info.source === 'health_json'); let healthJson = null; // health if (svc.health_url) { try { const ctrl = new AbortController(); const started = Date.now(); const t = setTimeout(() => ctrl.abort(), timeoutMs); const r = await fetch(svc.health_url, { signal: ctrl.signal }); clearTimeout(t); res.http_code = r.status; res.healthy = r.status === 200; res.ping_ms = Date.now() - started; if (r.status === 200 && wantsHealthJson) { try { const text = await r.text(); const trimmed = text.trim(); if (trimmed) healthJson = JSON.parse(trimmed); } catch { healthJson = null; } } } catch (e) { const cause = e && typeof e === 'object' ? e.cause : null; const extra = cause && typeof cause === 'object' ? (cause.code || cause.errno || cause.syscall || cause.message) : null; res.error = extra ? `${e.message || e} (${extra})` : String(e.message || e); res.healthy = false; } } // metrics if (svc.metrics_url) { try { const ctrl = new AbortController(); const started = Date.now(); const t = setTimeout(() => ctrl.abort(), timeoutMs); const r = await fetch(svc.metrics_url, { signal: ctrl.signal }); clearTimeout(t); if (r.status === 200) { const txt = await r.text(); const all = parseProm(txt); res.metrics = all; if (res.ping_ms === null) res.ping_ms = Date.now() - started; } else { res.metrics_error = `HTTP ${r.status}`; } } catch (e) { const cause = e && typeof e === 'object' ? e.cause : null; const extra = cause && typeof cause === 'object' ? (cause.code || cause.errno || cause.syscall || cause.message) : null; res.metrics_error = extra ? `${e.message || e} (${extra})` : String(e.message || e); } } try { res.extras = await collectExtras(svc, { healthJson, metrics: res.metrics, timeoutMs }); } catch (e) { res.extras = [{ label: 'Infos', value: `Erreur: ${e.message || e}` }]; } return res; } function buildEmbed(results) { const ok = results.filter(r => r.status.healthy === true || r.status.healthy === null).length; const total = results.length; const now = new Date(); const unixSeconds = Math.floor(now.getTime() / 1000); const discordTimestamp = ``; const emb = new EmbedBuilder() .setTitle('État du système') .setDescription(`**${ok}/${total} services nominal**\nDernière actualisation : ${discordTimestamp}`) .setColor(0x8D37C0) const metricsByService = {}; for (const r of results) { const base = { ...(r.status.metrics || {}) }; if (typeof r.status.ping_ms === 'number') base.ping_ms = r.status.ping_ms; metricsByService[r.name || 'Unnamed'] = base; } for (const r of results) { const { name, status } = r; const healthy = status.healthy; const icon = healthy === true ? '🟢' : (healthy === null ? '🟡' : '🔴'); let value = `${icon} **${name}**`; if (status.http_code !== null && status.http_code !== undefined && status.http_code !== 200) { value += ` — HTTP ${status.http_code}`; } if (status.error) value += `\n\`health:\` ${status.error}`; if (status.metrics_error) value += `\n\`metrics:\` ${status.metrics_error}`; const m = { ...(status.metrics || {}) }; if (typeof status.ping_ms === 'number') m.ping_ms = status.ping_ms; 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}`; } const extras = status.extras || []; if (extras.length) { const extraPretty = extras.map(({ label, value: extraVal }) => `- ${label}: ${formatExtraValue(extraVal)}`).join('\n'); value += `\n${extraPretty}`; } emb.addFields({ name: '\u200b', value, inline: false }); } return emb; } async function runChecks(services) { const out = []; for (const svc of services) { const status = await checkService(svc); out.push({ name: svc.name || 'Unnamed', status }); } return out; } const client = new Client({ intents: [GatewayIntentBits.Guilds] }); let pinnedMessageId = null; let statusChannel = null; async function ensureStatusChannel(client) { if (statusChannel && statusChannel.isTextBased() && statusChannel.guildId === STATUS_GUILD_ID) { return statusChannel; } statusChannel = await client.channels.fetch(STATUS_CHANNEL_ID).catch(() => null); if (!statusChannel || !statusChannel.isTextBased() || statusChannel.guildId !== STATUS_GUILD_ID) { console.error('Unable to resolve status channel in the configured guild.'); statusChannel = null; } if (statusChannel && STICKY_MESSAGE && !pinnedMessageId) { try { const pinned = await statusChannel.messages.fetchPins(); const existing = pinned.find((msg) => msg.author.id === client.user.id); if (existing) pinnedMessageId = existing.id; } catch {} } return statusChannel; } async function publishStatus(client) { const channel = await ensureStatusChannel(client); if (!channel) return; const cfg = loadConfig(); const results = await runChecks(cfg.services || []); const embed = buildEmbed(results); console.log(`[refresh] ${new Date().toISOString()} — services: ${results.length}`); if (STICKY_MESSAGE && pinnedMessageId) { try { const msg = await channel.messages.fetch(pinnedMessageId); await msg.edit({ embeds: [embed], allowedMentions: { parse: [] } }); return; } catch { pinnedMessageId = null; } } const sent = await channel.send({ embeds: [embed], allowedMentions: { parse: [] } }); if (STICKY_MESSAGE) { pinnedMessageId = sent.id; try { await sent.pin(); } catch {} } } client.once('clientReady', async () => { console.log(`Logged in as ${client.user.tag}`); try { await publishStatus(client); } catch (e) { console.error('Initial publish failed:', e); } setInterval(async () => { try { await publishStatus(client); } catch (e) { console.error('Periodic update failed:', e); } }, Math.max(10, REFRESH_INTERVAL) * 1000); }); client.login(DISCORD_TOKEN);