1er commit, mise en service
This commit is contained in:
674
src/bot.js
Normal file
674
src/bot.js
Normal file
@@ -0,0 +1,674 @@
|
||||
require('dotenv').config();
|
||||
const { Client, GatewayIntentBits, EmbedBuilder } = require('discord.js');
|
||||
const { fetch } = require('undici');
|
||||
const fs = require('fs');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
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 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(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 parseRss(text) {
|
||||
const channelMatch = /<channel>([\s\S]*?)<\/channel>/i.exec(text);
|
||||
const channelText = channelMatch ? channelMatch[1] : text;
|
||||
const items = [];
|
||||
const itemRegex = /<item>([\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'),
|
||||
author: extractTag(item, 'author'),
|
||||
pubDate: extractTag(item, 'pubDate'),
|
||||
guid: extractTag(item, 'guid'),
|
||||
});
|
||||
}
|
||||
return {
|
||||
rss: {
|
||||
channel: {
|
||||
title: extractTag(channelText, 'title'),
|
||||
link: extractTag(channelText, 'link'),
|
||||
description: extractTag(channelText, 'description'),
|
||||
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);
|
||||
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) throw new Error(`HTTP ${resp.status}`);
|
||||
const text = await resp.text();
|
||||
return parseRss(text);
|
||||
} 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 `<t:${unixSeconds}:f>`;
|
||||
}
|
||||
}
|
||||
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 = [];
|
||||
for (const url of urls) {
|
||||
let cached = dataCache.get(`rss:${url}`);
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
const norm = normalizeRssItem(pathVal);
|
||||
if (norm) items.push(norm);
|
||||
}
|
||||
}
|
||||
|
||||
if (info.aggregate === 'count') {
|
||||
value = items.length;
|
||||
} 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 };
|
||||
}
|
||||
}
|
||||
if (!latest) {
|
||||
value = undefined;
|
||||
} else if (info.aggregate === 'latest_title') {
|
||||
value = latest.entry.title || undefined;
|
||||
} else {
|
||||
if (latest.timestamp) {
|
||||
const unixSeconds = Math.floor(latest.timestamp / 1000);
|
||||
value = `<t:${unixSeconds}:f>`;
|
||||
} else {
|
||||
value = undefined;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
value = items.map(entry => entry.title).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',
|
||||
};
|
||||
|
||||
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 formatMetrics(name, allMetrics, metricsByService) {
|
||||
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 usersConnected = allMetrics.mare_authorized_connections ?? fallbackMetrics.mare_authorized_connections;
|
||||
const usersRegistered = allMetrics.mare_users_registered ?? fallbackMetrics.mare_users_registered;
|
||||
|
||||
entries.push([
|
||||
'mare_authorized_connections',
|
||||
usersConnected !== undefined ? usersConnected : 'N/A',
|
||||
]);
|
||||
entries.push([
|
||||
'mare_users_registered',
|
||||
usersRegistered !== undefined ? usersRegistered : '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') {
|
||||
return Object.values(value).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 = `<t:${unixSeconds}:f>`;
|
||||
|
||||
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).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);
|
||||
Reference in New Issue
Block a user