diff --git a/apps/api/src/alerting.ts b/apps/api/src/alerting.ts index 9c9ebc3..ee4746d 100644 --- a/apps/api/src/alerting.ts +++ b/apps/api/src/alerting.ts @@ -1,6 +1,22 @@ import type { FastifyInstance } from 'fastify'; import { prisma } from '@sentinel/db'; -import { ALERT_CATEGORIES, isAlertCategory, type AlertCategory, type AlertSeverity } from '@sentinel/shared'; +import { + ALERT_CATEGORIES, + ALERT_UI, + alertCategoryLabel, + alertFieldLabel, + durationText, + isAlertCategory, + isAlertMessageKey, + normalizeAlertLocale, + renderAlertMessage, + type AlertCategory, + type AlertField, + type AlertLocale, + type AlertMessageKey, + type AlertParams, + type AlertSeverity +} from '@sentinel/shared'; import { requireSuperAdmin } from './auth.js'; import { config } from './config.js'; import { deliverAlert, getGuildResources } from './discord.js'; @@ -10,7 +26,6 @@ import { superAdminAudit } from './super-audit.js'; import { SEVERITY_COLOR, digestDue, - formatDuration, groupForDelivery, inQuietHours, parseAlertSettings, @@ -31,22 +46,17 @@ import { type Logger = { info: (obj: object, msg: string) => void; warn: (obj: object, msg: string) => void }; type Level = 'info' | 'ok' | 'warn' | 'error'; -type Details = { description?: string | null; fields?: Array<{ name: string; value: string }> }; +type Details = { + params?: AlertParams; + fields?: Array; + /** Older rows: fixed (Italian) text. */ + description?: string | null; +}; const ROUTER_INTERVAL_MS = 20_000; const DELIVERY_CHECK_MS = 2 * 60_000; const PENDING_MAX_AGE_MS = 24 * 60 * 60_000; -export const CATEGORY_LABELS: Record = { - availability: 'Disponibilità', - security: 'Sicurezza', - errors: 'Errori', - delivery: 'Consegna dei log', - billing: 'Abbonamenti', - installations: 'Installazioni', - usage: 'Utilizzo' -}; - const LEVEL_COLOR: Record = { ok: SEVERITY_COLOR.ok, info: SEVERITY_COLOR.info, warn: SEVERITY_COLOR.warning, error: SEVERITY_COLOR.critical }; const levelFor = (severity: AlertSeverity): Level => severity === 'critical' ? 'error' : severity === 'warning' ? 'warn' : 'info'; @@ -58,20 +68,57 @@ const readDetails = (value: unknown): Details => { } }; +/** Title, description and fields of an alert in one language. */ +export function renderEvent(event: { messageKey: string | null; title: string; details: unknown }, locale: AlertLocale) { + const details = readDetails(event.details); + const fields = (details.fields ?? []).map((field) => 'key' in field + ? { name: alertFieldLabel(field.key, locale), value: field.value } + : { name: field.name, value: field.value }); + if (isAlertMessageKey(event.messageKey)) { + const text = renderAlertMessage(event.messageKey, details.params ?? {}, locale); + return { title: text.title, description: text.description, fields }; + } + return { title: event.title, description: details.description ?? null, fields }; +} + +export function renderIncident(incident: { messageKey: string | null; title: string; description: string | null; params: unknown }, locale: AlertLocale) { + if (isAlertMessageKey(incident.messageKey)) { + const params = incident.params && typeof incident.params === 'object' ? incident.params as AlertParams : {}; + const text = renderAlertMessage(incident.messageKey, params, locale); + return { title: text.title, description: text.description }; + } + return { title: incident.title, description: incident.description }; +} + +/** Language of each target server ("message language" in its dashboard). */ +async function localesOf(guildIds: string[]) { + const unique = [...new Set(guildIds)]; + const rows = unique.length ? await prisma.guildSettings.findMany({ where: { guildId: { in: unique } }, select: { guildId: true, locale: true } }) : []; + const map = new Map(rows.map((row) => [row.guildId, normalizeAlertLocale(row.locale)])); + return (guildId: string | null | undefined): AlertLocale => (guildId ? map.get(guildId) ?? 'en' : 'en'); +} + +export async function statusLocale() { + const status = await prisma.statusChannel.findUnique({ where: { id: 1 }, select: { guildId: true } }); + return (await localesOf(status ? [status.guildId] : []))(status?.guildId); +} + /* ------------------------------------------------------------- recording */ export async function recordAlert(input: { - category: AlertCategory; severity: AlertSeverity; title: string; description?: string | null; - fields?: Array<{ name: string; value: string }>; level?: Level; logOnly?: boolean; + category: AlertCategory; severity: AlertSeverity; key: AlertMessageKey; params?: AlertParams; + fields?: AlertField[]; level?: Level; logOnly?: boolean; }) { + const params = input.params ?? {}; await prisma.instanceEvent.create({ data: { source: 'api', level: input.level ?? levelFor(input.severity), category: input.category, severity: input.severity, - title: input.title.slice(0, 256), - details: protectJson({ description: input.description ?? null, fields: input.fields ?? [] }), + messageKey: input.key, + title: renderAlertMessage(input.key, params, 'en').title.slice(0, 256), + details: protectJson({ params, fields: input.fields ?? [] }), deliveryState: input.logOnly ? 'log' : 'pending' } }); @@ -79,24 +126,28 @@ export async function recordAlert(input: { /** Opens an incident once per key; repeated calls while it is open do nothing. */ export async function openIncident(key: string, input: { - category: AlertCategory; severity: AlertSeverity; title: string; description: string; logOnly?: boolean; + category: AlertCategory; severity: AlertSeverity; message: AlertMessageKey; params?: AlertParams; logOnly?: boolean; }) { const open = await prisma.incident.findFirst({ where: { key, resolvedAt: null } }); if (open) return false; - await prisma.incident.create({ data: { key, category: input.category, severity: input.severity, title: input.title, description: input.description } }); - await recordAlert({ category: input.category, severity: input.severity, title: input.title, description: input.description, logOnly: input.logOnly }); + const params = input.params ?? {}; + const text = renderAlertMessage(input.message, params, 'en'); + await prisma.incident.create({ + data: { key, category: input.category, severity: input.severity, title: text.title, description: text.description, messageKey: input.message, params: params as Record } + }); + await recordAlert({ category: input.category, severity: input.severity, key: input.message, params, logOnly: input.logOnly }); return true; } /** Resolves the open incident of a key and announces it with its duration. */ -export async function resolveIncident(key: string, message: { title: string; description: (duration: string) => string }) { +export async function resolveIncident(key: string, message: AlertMessageKey, params: AlertParams = {}) { const open = await prisma.incident.findFirst({ where: { key, resolvedAt: null } }); if (!open) return null; const now = new Date(); await prisma.incident.update({ where: { id: open.id }, data: { resolvedAt: now } }); - const duration = formatDuration(now.getTime() - open.openedAt.getTime()); - await recordAlert({ category: isAlertCategory(open.category) ? open.category : 'errors', severity: 'info', level: 'ok', title: message.title, description: message.description(duration) }); - return duration; + const durationMs = now.getTime() - open.openedAt.getTime(); + await recordAlert({ category: isAlertCategory(open.category) ? open.category : 'errors', severity: 'info', level: 'ok', key: message, params: { ...params, durationMs } }); + return durationMs; } /* --------------------------------------------------------------- webhook */ @@ -109,14 +160,14 @@ export async function alertWebhookUrl() { } /** Optional fallback for "bot down": a webhook works while the bot is stopped. */ -export async function postAlertWebhook(url: string, level: Level, title: string, description: string) { +export async function postAlertWebhook(url: string, level: Level, title: string, description: string, locale: AlertLocale = 'en') { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'Sentinel monitor', allowed_mentions: { parse: [] }, - embeds: [{ title, description, color: WEBHOOK_COLORS[level], timestamp: new Date().toISOString(), footer: { text: 'Sentinel · monitoraggio' } }] + embeds: [{ title, description, color: WEBHOOK_COLORS[level], timestamp: new Date().toISOString(), footer: { text: ALERT_UI[locale].monitorFooter } }] }), signal: AbortSignal.timeout(8_000) }); @@ -163,7 +214,7 @@ export async function runAlertRouter(log: Logger) { if (!target) { toLog.push(event.id); continue; } // Quiet hours: non-critical alerts wait and leave together in the morning. if (quiet && event.severity !== 'critical') continue; - sendable.push({ key: `${target.guildId}:${target.channelId}:${event.title}`, event, target, mention: mentionFor(route.mention, event.severity), category }); + sendable.push({ key: `${target.guildId}:${target.channelId}:${event.messageKey ?? event.title}`, event, target, mention: mentionFor(route.mention, event.severity), category }); } await Promise.all([ toLog.length ? prisma.instanceEvent.updateMany({ where: { id: { in: toLog } }, data: { deliveryState: 'log' } }) : null, @@ -171,20 +222,23 @@ export async function runAlertRouter(log: Logger) { expired.length ? prisma.instanceEvent.updateMany({ where: { id: { in: expired } }, data: { deliveryState: 'failed' } }) : null ]); + const localeOf = await localesOf(sendable.map((item) => item.target.guildId)); for (const group of groupForDelivery(sendable, lastSent, now.getTime())) { const first = group.members[0]!; const latest = group.members[group.members.length - 1]!; - const details = readDetails(latest.event.details); + const locale = localeOf(latest.target.guildId); + const ui = ALERT_UI[locale]; + const text = renderEvent(latest.event, locale); const count = group.members.length; - const since = first.event.createdAt.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit', timeZone: settings.timezone }); + const since = first.event.createdAt.toLocaleTimeString(locale === 'it' ? 'it-IT' : 'en-GB', { hour: '2-digit', minute: '2-digit', timeZone: settings.timezone }); try { await deliverAlert({ ...latest.target, - title: count > 1 ? `${latest.event.title} ×${count}` : latest.event.title, - description: [details.description, count > 1 ? `${count} avvisi uguali dalle ${since}; qui sopra il più recente.` : null].filter(Boolean).join('\n\n'), - fields: details.fields ?? [], + title: count > 1 ? `${text.title} ×${count}` : text.title, + description: [text.description, count > 1 ? ui.repeated(count, since) : null].filter(Boolean).join('\n\n'), + fields: text.fields, color: LEVEL_COLOR[(latest.event.level as Level)] ?? LEVEL_COLOR.info, - footer: `Sentinel · ${CATEGORY_LABELS[latest.category]}`, + footer: ui.footer(alertCategoryLabel(latest.category, locale)), mentionUserId: group.members.some((member) => member.mention) ? latest.mention ?? first.mention : null }); lastSent.set(group.key, now.getTime()); @@ -213,25 +267,28 @@ async function sendDigest(settings: AlertSettings, status: AlertTarget | null, n return; } + const locale = (await localesOf([target.guildId]))(target.guildId); + const ui = ALERT_UI[locale]; const byCategory = new Map>(); for (const event of events) { const category: AlertCategory = isAlertCategory(event.category) ? event.category : 'errors'; const titles = byCategory.get(category) ?? new Map(); - titles.set(event.title, (titles.get(event.title) ?? 0) + 1); + const title = renderEvent(event, locale).title; + titles.set(title, (titles.get(title) ?? 0) + 1); byCategory.set(category, titles); } const fields = ALERT_CATEGORIES.filter((category) => byCategory.has(category)).map((category) => ({ - name: CATEGORY_LABELS[category], + name: alertCategoryLabel(category, locale), value: [...byCategory.get(category)!.entries()].sort((a, b) => b[1] - a[1]).map(([title, count]) => `• ${title}: **${count}**`).join('\n').slice(0, 1024) })); try { await deliverAlert({ ...target, - title: 'Riepilogo giornaliero', - description: `${events.length} avvisi informativi dall’ultimo riepilogo. I dettagli sono nella super console → Monitoraggio.`, + title: ui.digestTitle, + description: ui.digestText(events.length), fields, color: SEVERITY_COLOR.info, - footer: 'Sentinel · riepilogo', + footer: ui.digestFooter, mentionUserId: null }); await prisma.instanceEvent.updateMany({ where: { id: { in: events.map((event) => event.id) } }, data: { deliveryState: 'digested', deliveredAt: now } }); @@ -246,24 +303,30 @@ async function sendReminders(settings: AlertSettings, status: AlertTarget | null for (const incident of open) { if (!reminderDue(incident, now)) continue; if (quiet && incident.severity !== 'critical') continue; - const description = `Ancora aperto da ${formatDuration(now.getTime() - incident.openedAt.getTime())}. Segnalo come preso in carico nella super console → Monitoraggio per non ricevere altri promemoria.`; try { if (incident.key === 'bot.down') { // The bot cannot post while it is down: only the webhook can remind. const url = await alertWebhookUrl().catch(() => null); - if (url) await postAlertWebhook(url, 'error', `Promemoria: ${incident.title}`, description); + if (url) { + const locale = await statusLocale(); + const text = renderIncident(incident, locale); + await postAlertWebhook(url, 'error', ALERT_UI[locale].reminder(text.title), ALERT_UI[locale].reminderText(durationText(now.getTime() - incident.openedAt.getTime(), locale)), locale); + } } else { const category: AlertCategory = isAlertCategory(incident.category) ? incident.category : 'errors'; const route = settings.routes[category]; const target = route.mode === 'status' || route.mode === 'channel' ? routeTarget(route, status) : status; if (target) { + const locale = (await localesOf([target.guildId]))(target.guildId); + const ui = ALERT_UI[locale]; + const text = renderIncident(incident, locale); await deliverAlert({ ...target, - title: `Promemoria: ${incident.title}`, - description: [incident.description, description].filter(Boolean).join('\n\n'), + title: ui.reminder(text.title), + description: [text.description, ui.reminderText(durationText(now.getTime() - incident.openedAt.getTime(), locale))].filter(Boolean).join('\n\n'), fields: [], color: incident.severity === 'critical' ? SEVERITY_COLOR.critical : SEVERITY_COLOR.warning, - footer: `Sentinel · ${CATEGORY_LABELS[category]}`, + footer: ui.footer(alertCategoryLabel(category, locale)), mentionUserId: mentionFor(route.mention, incident.severity) }); } @@ -285,14 +348,14 @@ async function checkDelivery() { prisma.logEvent.count({ where: { dispatchState: 'FAILED', createdAt: { gt: new Date(now - 15 * 60_000) } } }) ]); if (backlog >= 100) { - await openIncident('delivery.backlog', { category: 'delivery', severity: 'warning', title: 'Coda dei log in accumulo', description: `${backlog} log attendono la consegna da più di 10 minuti: il dispatcher del bot è fermo o rallentato.` }); + await openIncident('delivery.backlog', { category: 'delivery', severity: 'warning', message: 'delivery.backlog', params: { count: backlog } }); } else if (backlog <= 20) { - await resolveIncident('delivery.backlog', { title: 'Coda dei log smaltita', description: (duration) => `La consegna dei log è tornata regolare dopo ${duration}.` }); + await resolveIncident('delivery.backlog', 'delivery.backlogCleared'); } if (failures >= 25) { - await openIncident('delivery.failures', { category: 'delivery', severity: 'warning', title: 'Molti invii dei log falliti', description: `${failures} log rifiutati da Discord negli ultimi 15 minuti.` }); + await openIncident('delivery.failures', { category: 'delivery', severity: 'warning', message: 'delivery.failures', params: { count: failures } }); } else if (failures < 5) { - await resolveIncident('delivery.failures', { title: 'Invii dei log tornati regolari', description: (duration) => `Gli invii falliti sono rientrati dopo ${duration}.` }); + await resolveIncident('delivery.failures', 'delivery.failuresCleared'); } } @@ -307,12 +370,14 @@ export async function registerAlertRoutes(app: FastifyInstance) { app.get('/api/super/alerts', async (request, reply) => { const session = await requireSuperAdmin(request, reply); if (!session) return; + const locale = normalizeAlertLocale((request.query as { locale?: string }).locale); const [{ settings }, open, recent] = await Promise.all([ loadSettings(), prisma.incident.findMany({ where: { resolvedAt: null }, orderBy: { openedAt: 'desc' } }), prisma.incident.findMany({ where: { resolvedAt: { not: null } }, orderBy: { openedAt: 'desc' }, take: 30 }) ]); - return { settings, incidents: { open, recent }, mentionAvailable: Boolean(config.superAdminUserId) }; + const view = (incident: (typeof open)[number]) => ({ ...incident, ...renderIncident(incident, locale) }); + return { settings, incidents: { open: open.map(view), recent: recent.map(view) }, mentionAvailable: Boolean(config.superAdminUserId) }; }); app.put('/api/super/alerts/settings', async (request, reply) => { diff --git a/apps/api/src/discord-billing.ts b/apps/api/src/discord-billing.ts index b467be4..e2967bb 100644 --- a/apps/api/src/discord-billing.ts +++ b/apps/api/src/discord-billing.ts @@ -40,18 +40,13 @@ export async function syncDiscordPlans(log: Logger): Promise { lastSync = { at: new Date().toISOString(), ok: false, entitlements: 0, changed: 0, error: 'ENTITLEMENTS_UNAVAILABLE' }; consecutiveFailures += 1; if (consecutiveFailures >= FAILURES_BEFORE_INCIDENT) { - await openIncident('discord.sync', { - category: 'billing', - severity: 'warning', - title: 'Sincronizzazione abbonamenti Discord ferma', - description: 'Gli abbonamenti attivi non si possono leggere da Discord: nuovi acquisti, rinnovi e disdette non arrivano ai piani dei server.' - }).catch(() => null); + await openIncident('discord.sync', { category: 'billing', severity: 'warning', message: 'sync.stopped' }).catch(() => null); } return lastSync; } consecutiveFailures = 0; - await resolveIncident('discord.sync', { title: 'Sincronizzazione abbonamenti ripristinata', description: (duration) => `Gli abbonamenti Discord tornano ad arrivare ai piani dopo ${duration}.` }).catch(() => null); + await resolveIncident('discord.sync', 'sync.restored').catch(() => null); const now = new Date(); const discordPlans = plansFromEntitlements(entitlements, skus, now); const rows = await prisma.guildSettings.findMany({ @@ -86,9 +81,9 @@ export async function syncDiscordPlans(log: Logger): Promise { }); await recordAlert({ category: 'billing', severity: 'info', level: 'ok', - title: row.planSource === 'DISCORD' && row.planTier !== plan.tier ? 'Abbonamento Discord cambiato' : 'Nuovo abbonamento Discord', - description: `Il server ora ha il piano ${PLANS[plan.tier].name}.`, - fields: [{ name: 'Server', value: `\`${row.guildId}\`` }] + key: row.planSource === 'DISCORD' && row.planTier !== plan.tier ? 'subscription.changed' : 'subscription.new', + params: { plan: PLANS[plan.tier].name }, + fields: [{ key: 'server', value: `\`${row.guildId}\`` }] }).catch(() => null); } } else { @@ -100,9 +95,8 @@ export async function syncDiscordPlans(log: Logger): Promise { await systemAudit('plan.discord_ended', 'GUILD', row.guildId, { previousTier: row.planTier, entitlementId: row.planEntitlementId, applied }); await recordAlert({ category: 'billing', severity: 'info', - title: 'Abbonamento Discord terminato', - description: 'Il server è tornato al piano Free.', - fields: [{ name: 'Server', value: `\`${row.guildId}\`` }] + key: 'subscription.ended', + fields: [{ key: 'server', value: `\`${row.guildId}\`` }] }).catch(() => null); } changed += 1; diff --git a/apps/api/src/monitoring.ts b/apps/api/src/monitoring.ts index b6cdff5..213886b 100644 --- a/apps/api/src/monitoring.ts +++ b/apps/api/src/monitoring.ts @@ -4,9 +4,10 @@ import { prisma } from '@sentinel/db'; import { LOG_LEVEL_RANK, type LogBuffer, type LogLevelName } from '@sentinel/shared'; import { requireSuperAdmin } from './auth.js'; import { botHealth, getBotLogs } from './discord.js'; -import { encryptText, unprotectJson } from './security.js'; +import { encryptText } from './security.js'; import { superAdminAudit } from './super-audit.js'; -import { alertWebhookUrl, openIncident, postAlertWebhook, resolveIncident } from './alerting.js'; +import { alertWebhookUrl, openIncident, postAlertWebhook, renderEvent, resolveIncident, statusLocale } from './alerting.js'; +import { normalizeAlertLocale, renderAlertMessage, type AlertMessageKey, type AlertParams } from '@sentinel/shared'; /** * Super console monitoring: the bot watchdog, the instance event feed and the @@ -32,9 +33,12 @@ type BotState = { status: 'unknown' | 'up' | 'down'; since: string; lastCheck: s const state: BotState = { status: 'unknown', since: new Date().toISOString(), lastCheck: null, failures: 0, guilds: null }; export const botState = () => ({ ...state }); -async function webhook(level: 'ok' | 'error', title: string, description: string, log: Logger) { +async function webhook(level: 'ok' | 'error', key: AlertMessageKey, params: AlertParams, log: Logger) { const url = await alertWebhookUrl().catch(() => null); - if (url) await postAlertWebhook(url, level, title, description).catch((error) => log.warn({ err: error }, 'Alarm webhook failed')); + if (!url) return; + const locale = await statusLocale().catch(() => 'en' as const); + const text = renderAlertMessage(key, params, locale); + await postAlertWebhook(url, level, text.title, text.description ?? '', locale).catch((error) => log.warn({ err: error }, 'Alarm webhook failed')); } async function checkBot(log: Logger) { @@ -49,11 +53,8 @@ async function checkBot(log: Logger) { state.since = now.toISOString(); } // Also closes an incident left open by a previous API process. - const downtime = await resolveIncident('bot.down', { - title: 'Bot ripristinato', - description: (duration) => `Sentinel è di nuovo connesso a Discord dopo ${duration} di interruzione (${health.guilds} server).` - }).catch(() => null); - if (downtime) await webhook('ok', 'Bot ripristinato', `Sentinel è di nuovo connesso a Discord dopo ${downtime} di interruzione.`, log); + const durationMs = await resolveIncident('bot.down', 'bot.restored', { guilds: health.guilds }).catch(() => null); + if (durationMs) await webhook('ok', 'bot.restored', { guilds: health.guilds, durationMs }, log); return; } state.failures += 1; @@ -61,12 +62,10 @@ async function checkBot(log: Logger) { const wasUp = state.status === 'up'; state.status = 'down'; state.since = now.toISOString(); - const description = health - ? 'Il processo del bot risponde ma non è connesso a Discord: i log non vengono registrati.' - : `Il bot non risponde da circa ${Math.round(FAILURES_BEFORE_DOWN * CHECK_INTERVAL_MS / 60_000)} minuti${wasUp ? '' : ' (dall’avvio dell’API)'}: i log non vengono registrati.`; + const params = { mode: health ? 'disconnected' : 'silent', minutes: Math.round(FAILURES_BEFORE_DOWN * CHECK_INTERVAL_MS / 60_000), sinceStart: wasUp ? null : 1 }; // logOnly: the bot cannot post it; the webhook and the external monitor do. - const opened = await openIncident('bot.down', { category: 'availability', severity: 'critical', title: 'Bot non raggiungibile', description, logOnly: true }).catch(() => false); - if (opened) await webhook('error', 'Bot non raggiungibile', description, log); + const opened = await openIncident('bot.down', { category: 'availability', severity: 'critical', message: 'bot.down', params, logOnly: true }).catch(() => false); + if (opened) await webhook('error', 'bot.down', params, log); } } @@ -88,18 +87,14 @@ export async function registerMonitoringRoutes(app: FastifyInstance, apiLogs: Lo app.get('/api/super/monitor', async (request, reply) => { const session = await requireSuperAdmin(request, reply); if (!session) return; + const locale = normalizeAlertLocale((request.query as { locale?: string }).locale); const [rows, url] = await Promise.all([ prisma.instanceEvent.findMany({ orderBy: { createdAt: 'desc' }, take: 200 }), alertWebhookUrl().catch(() => null) ]); const events = rows.map((row) => { - let details: { description?: string | null; fields?: Array<{ name: string; value: string }> } = {}; - try { - details = unprotectJson(row.details) as typeof details; - } catch { - details = { description: null, fields: [] }; - } - return { id: row.id, source: row.source, level: row.level, category: row.category, severity: row.severity, deliveryState: row.deliveryState, title: row.title, description: details.description ?? null, fields: details.fields ?? [], createdAt: row.createdAt }; + const text = renderEvent(row, locale); + return { id: row.id, source: row.source, level: row.level, category: row.category, severity: row.severity, deliveryState: row.deliveryState, title: text.title, description: text.description, fields: text.fields, createdAt: row.createdAt }; }); return { bot: botState(), events, webhook: url ? { configured: true, hint: `…${url.slice(-6)}` } : { configured: false, hint: null } }; }); @@ -140,7 +135,9 @@ export async function registerMonitoringRoutes(app: FastifyInstance, apiLogs: Lo const url = await alertWebhookUrl().catch(() => null); if (!url) return reply.code(400).send({ error: 'WEBHOOK_NOT_SET' }); try { - await postAlertWebhook(url, 'info', 'Allarmi attivi', 'Messaggio di prova dalla super console: qui arriveranno l’avviso e i promemoria quando il bot non è raggiungibile, e la notizia del ripristino.'); + const locale = await statusLocale().catch(() => 'en' as const); + const text = renderAlertMessage('webhook.test', {}, locale); + await postAlertWebhook(url, 'info', text.title, text.description ?? '', locale); } catch { return reply.code(502).send({ error: 'WEBHOOK_FAILED' }); } diff --git a/apps/bot/src/handlers.ts b/apps/bot/src/handlers.ts index 630671e..5ebaf6f 100644 --- a/apps/bot/src/handlers.ts +++ b/apps/bot/src/handlers.ts @@ -90,20 +90,16 @@ export function registerHandlers(client: Client, status: StatusNotifier) { intentionalLeaves.add(guild.id); setTimeout(() => intentionalLeaves.delete(guild.id), 120_000).unref(); await leaveGuild(guild, reason).catch(() => null); - const why = { - 'blocked-guild': 'il server è in blacklist', - 'blocked-installer': 'chi ha aggiunto il bot è in blacklist' - }[reason]; status.notify({ level: 'warn', category: 'security', severity: 'warning', - title: 'Installazione rifiutata', - description: `Sentinel è uscito da **${guild.name}**: ${why}.`, + key: 'install.rejected', + params: { guild: guild.name, reason }, fields: [ - { name: 'Server', value: `${guild.name}\n\`${guild.id}\`` }, - { name: 'Membri', value: String(guild.memberCount) }, - { name: 'Aggiunto da', value: installerId ? `<@${installerId}>\n\`${installerId}\`` : 'sconosciuto' } + { key: 'server', value: `${guild.name}\n\`${guild.id}\`` }, + { key: 'members', value: String(guild.memberCount) }, + { key: 'addedBy', value: installerId ? `<@${installerId}>\n\`${installerId}\`` : '—' } ] }); }; @@ -128,11 +124,11 @@ export function registerHandlers(client: Client, status: StatusNotifier) { level: 'ok', category: 'installations', severity: 'info', - title: 'Sentinel aggiunto a un server', + key: 'guild.added', fields: [ - { name: 'Server', value: `${guild.name}\n\`${guild.id}\`` }, - { name: 'Membri', value: String(guild.memberCount) }, - { name: 'Aggiunto da', value: installerId ? `<@${installerId}>\n\`${installerId}\`` : 'sconosciuto' } + { key: 'server', value: `${guild.name}\n\`${guild.id}\`` }, + { key: 'members', value: String(guild.memberCount) }, + { key: 'addedBy', value: installerId ? `<@${installerId}>\n\`${installerId}\`` : '—' } ] }); await sendWelcome(guild); @@ -750,8 +746,8 @@ export function registerHandlers(client: Client, status: StatusNotifier) { level: 'info', category: 'installations', severity: 'info', - title: 'Sentinel rimosso da un server', - fields: [{ name: 'Server', value: `${guild.name ?? 'sconosciuto'}\n\`${guild.id}\`` }] + key: 'guild.removed', + fields: [{ key: 'server', value: `${guild.name ?? '—'}\n\`${guild.id}\`` }] }); } await markGuildRemoved(guild.id); diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 9f5519c..3ca0a10 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -48,8 +48,8 @@ installDiscordCapabilityPolicy(client, ({ method, route }) => { level: 'error', category: 'security', severity: 'critical', - title: 'Operazione bloccata dalla politica di sicurezza', - description: `Il bot ha tentato una chiamata non consentita e la politica l’ha bloccata: \`${method} ${route}\`.`, + key: 'policy.blocked', + params: { method, route }, dedupeKey: `policy:${method}:${route}` }); }); @@ -62,9 +62,9 @@ onQuotaExceeded((guildId, limit, tier) => { level: 'warn', category: 'usage', severity: 'info', - title: 'Quota giornaliera raggiunta', - description: `**${guild?.name ?? guildId}** ha superato ${limit.toLocaleString('it-IT')} eventi oggi (piano ${tier}): gli eventi successivi non vengono registrati fino a mezzanotte UTC.`, - fields: [{ name: 'Server', value: `\`${guildId}\`` }], + key: 'quota.reached', + params: { guild: guild?.name ?? guildId, limit, plan: tier }, + fields: [{ key: 'server', value: `\`${guildId}\`` }], dedupeKey: `quota:${guildId}` }); }); @@ -95,11 +95,11 @@ client.once(Events.ClientReady, async (ready) => { level: 'ok', category: 'availability', severity: 'info', - title: 'Sentinel online', - description: `Connesso a Discord come **${ready.user.tag}**.`, + key: 'bot.online', + params: { user: ready.user.tag }, fields: [ - { name: 'Server', value: String(ready.guilds.cache.size) }, - { name: 'Avvio', value: `` } + { key: 'servers', value: String(ready.guilds.cache.size) }, + { key: 'startedAt', value: `` } ] }); await markMissingGuildsRemoved(ready.guilds.cache.keys()) @@ -120,9 +120,9 @@ async function runMaintenance() { level: 'info', category: 'installations', severity: 'info', - title: 'Dati di server rimossi cancellati', - description: `Cancellati i dati di ${purged.length} server da cui Sentinel è stato rimosso da più di ${REMOVED_GUILD_GRACE_DAYS} giorni.`, - fields: [{ name: 'Server', value: purged.slice(0, 20).map((id) => `\`${id}\``).join('\n') }] + key: 'guilds.purged', + params: { count: purged.length, days: REMOVED_GUILD_GRACE_DAYS }, + fields: [{ key: 'servers', value: purged.slice(0, 20).map((id) => `\`${id}\``).join('\n') }] }); } } catch (error) { @@ -134,16 +134,16 @@ async function runMaintenance() { // console, not to the servers being logged. client.on(Events.Warn, (warning) => { logger.warn({ warning: redactText(warning) }, 'Discord client warning'); - status.notify({ level: 'warn', category: 'errors', severity: 'warning', title: 'Warning del client Discord', description: redactText(warning) }); + status.notify({ level: 'warn', category: 'errors', severity: 'warning', key: 'client.warning', params: { detail: redactText(warning) } }); }); client.on(Events.Error, (error) => { logger.error({ error: errorText(error) }, 'Discord client error'); - status.notify({ level: 'error', category: 'errors', severity: 'warning', title: 'Errore del client Discord', description: errorText(error) }); + status.notify({ level: 'error', category: 'errors', severity: 'warning', key: 'client.error', params: { detail: errorText(error) } }); }); client.on(Events.ShardResume, (shardId, replayed) => { - status.notify({ level: 'info', category: 'availability', severity: 'info', title: 'Connessione a Discord ripristinata', description: `Shard ${shardId}: ${replayed} eventi recuperati.`, dedupeKey: 'shard-resume' }); + status.notify({ level: 'info', category: 'availability', severity: 'info', key: 'shard.resumed', params: { shard: shardId, replayed }, dedupeKey: 'shard-resume' }); }); setInterval(() => void runMaintenance(), 6 * 60 * 60 * 1000).unref(); @@ -171,7 +171,7 @@ const shutdown = async (signal: string) => { process.on('unhandledRejection', (reason) => { logger.error({ reason: errorText(reason) }, 'Unhandled promise rejection'); - status.notify({ level: 'error', category: 'errors', severity: 'warning', title: 'Errore non gestito nel bot', description: errorText(reason) }); + status.notify({ level: 'error', category: 'errors', severity: 'warning', key: 'bot.unhandled', params: { detail: errorText(reason) } }); }); process.on('uncaughtException', (error) => { diff --git a/apps/bot/src/internal-api.ts b/apps/bot/src/internal-api.ts index 4f24a10..48756f4 100644 --- a/apps/bot/src/internal-api.ts +++ b/apps/bot/src/internal-api.ts @@ -225,8 +225,7 @@ export function startInternalApi(client: Client, secret: string, port = 3002, st level: 'info', category: 'availability', severity: 'info', - title: 'Notifiche di stato attive', - description: 'Messaggio di prova inviato dalla super console. Qui arriveranno avvio del bot, errori e server aggiunti o rimossi.' + key: 'status.test' }, { force: true }); res.end(JSON.stringify(result)); return; diff --git a/apps/bot/src/status.ts b/apps/bot/src/status.ts index c88c8f1..76811fa 100644 --- a/apps/bot/src/status.ts +++ b/apps/bot/src/status.ts @@ -3,7 +3,8 @@ import { prisma } from '@sentinel/db'; import { sendStatusMessage } from './dispatcher.js'; import { truncate } from './embed-format.js'; import { logger } from './logger.js'; -import type { AlertCategory, AlertSeverity } from '@sentinel/shared'; +import { ALERT_UI, alertFieldLabel, normalizeAlertLocale, renderAlertMessage, type AlertCategory, type AlertField, type AlertMessageKey, type AlertParams, type AlertSeverity } from '@sentinel/shared'; +import { getGuildSettings } from './store.js'; import { protectJson, redactText } from './security.js'; export type StatusLevel = 'info' | 'ok' | 'warn' | 'error'; @@ -14,9 +15,10 @@ export type StatusNotice = { category: AlertCategory; /** critical: mention and reminders, ignores quiet hours; warning; info. */ severity: AlertSeverity; - title: string; - description?: string; - fields?: Array<{ name: string; value: string; inline?: boolean }>; + /** Text key: rendered in the language of the server it is posted to. */ + key: AlertMessageKey; + params?: AlertParams; + fields?: AlertField[]; /** Notices sharing a key are sent at most once per DEDUPE_MS. */ dedupeKey?: string; }; @@ -82,11 +84,19 @@ export function createStatusNotifier(client: Client): StatusNotifier { * status channel is set. createMany: the bot may insert but not read back. */ const record = async (notice: StatusNotice) => { - const description = notice.description ? redactText(notice.description).slice(0, 4000) : null; - const fields = (notice.fields ?? []).filter((field) => field.value).slice(0, 20) - .map((field) => ({ name: field.name.slice(0, 256), value: redactText(field.value).slice(0, 1024) })); + const params = Object.fromEntries(Object.entries(notice.params ?? {}).map(([key, value]) => [key, typeof value === 'string' ? redactText(value).slice(0, 2000) : value])); + const fields = (notice.fields ?? []).filter((field) => field.value).slice(0, 20).map((field) => ({ key: field.key, value: redactText(field.value).slice(0, 1024) })); await prisma.instanceEvent.createMany({ - data: [{ source: 'bot', level: notice.level, category: notice.category, severity: notice.severity, title: notice.title.slice(0, 256), details: protectJson({ description, fields }) }] + data: [{ + source: 'bot', + level: notice.level, + category: notice.category, + severity: notice.severity, + messageKey: notice.key, + // Readable in the database; the delivered text is rendered per server. + title: renderAlertMessage(notice.key, params, 'en').title.slice(0, 256), + details: protectJson({ params, fields }) + }] }).catch((error: unknown) => logger.warn({ error: redactText(String(error)) }, 'Unable to record instance event')); }; @@ -100,16 +110,18 @@ export function createStatusNotifier(client: Client): StatusNotifier { } if (!config) return { ok: false, error: 'STATUS_CHANNEL_NOT_SET' }; + const locale = normalizeAlertLocale((await getGuildSettings(config.guildId).catch(() => null))?.locale); + const text = renderAlertMessage(notice.key, notice.params ?? {}, locale); const embed = new EmbedBuilder() - .setTitle(truncate(notice.title, 256)) + .setTitle(truncate(text.title, 256)) .setColor(COLORS[notice.level]) - .setFooter({ text: 'Sentinel · stato del bot' }) + .setFooter({ text: ALERT_UI[locale].statusFooter }) .setTimestamp(new Date()); - if (notice.description) embed.setDescription(truncate(redactText(notice.description), 4000)); + if (text.description) embed.setDescription(truncate(redactText(text.description), 4000)); const fields = (notice.fields ?? []) .filter((field) => field.value) .slice(0, 20) - .map((field) => ({ name: truncate(field.name, 256), value: truncate(redactText(field.value), 1024), inline: field.inline ?? true })); + .map((field) => ({ name: truncate(alertFieldLabel(field.key, locale), 256), value: truncate(redactText(field.value), 1024), inline: true })); if (fields.length) embed.addFields(fields); try { diff --git a/apps/web/app/_components/GuildDashboard.tsx b/apps/web/app/_components/GuildDashboard.tsx index 12e754f..28264b5 100644 --- a/apps/web/app/_components/GuildDashboard.tsx +++ b/apps/web/app/_components/GuildDashboard.tsx @@ -448,7 +448,7 @@ export default function GuildDashboard({ guildId, locale }: { guildId: string; l ]} /> -
+
diff --git a/apps/web/app/_components/SuperConsole.tsx b/apps/web/app/_components/SuperConsole.tsx index ff2ed3b..35b04cc 100644 --- a/apps/web/app/_components/SuperConsole.tsx +++ b/apps/web/app/_components/SuperConsole.tsx @@ -90,7 +90,7 @@ const copy = { }, alerts: { routingTitle: 'Instradamento degli allarmi', - routingText: 'Scegli dove va ogni categoria. Gli avvisi uguali entro 10 minuti diventano un solo messaggio; i critici ti menzionano e ignorano gli orari di silenzio. “Bot giù” lo segnala il monitor esterno (e il webhook, se impostato).', + routingText: 'Scegli dove va ogni categoria. I messaggi usano la lingua impostata nel server di destinazione (dashboard → Lingua dei messaggi). Gli avvisi uguali entro 10 minuti diventano un solo messaggio; i critici ti menzionano e ignorano gli orari di silenzio. “Bot giù” lo segnala il monitor esterno (e il webhook, se impostato).', categories: { availability: 'Disponibilità', security: 'Sicurezza', errors: 'Errori', delivery: 'Consegna dei log', billing: 'Abbonamenti', installations: 'Installazioni', usage: 'Utilizzo' }, modes: { status: 'Canale di stato', channel: 'Altro canale', digest: 'Riepilogo giornaliero', log: 'Solo registro' }, server: 'Server', @@ -262,7 +262,7 @@ const copy = { }, alerts: { routingTitle: 'Alert routing', - routingText: 'Choose where each category goes. Identical alerts within 10 minutes become a single message; critical ones mention you and ignore quiet hours. “Bot down” is reported by the external monitor (and by the webhook, if set).', + routingText: 'Choose where each category goes. Messages use the language set in the destination server (dashboard → Message language). Identical alerts within 10 minutes become a single message; critical ones mention you and ignore quiet hours. “Bot down” is reported by the external monitor (and by the webhook, if set).', categories: { availability: 'Availability', security: 'Security', errors: 'Errors', delivery: 'Log delivery', billing: 'Subscriptions', installations: 'Installations', usage: 'Usage' }, modes: { status: 'Status channel', channel: 'Another channel', digest: 'Daily digest', log: 'Log only' }, server: 'Server', @@ -498,7 +498,7 @@ export default function SuperConsole({ locale }: { locale: Locale }) { {tab === 'servers' && } - {tab === 'monitor' && } + {tab === 'monitor' && } {tab === 'logs' && } {tab === 'contacts' && } {tab === 'discord' && } @@ -645,10 +645,10 @@ type Monitor = { webhook: { configured: boolean; hint: string | null }; }; -function MonitorTab({ data, c, action, fmt, setStatus }: { data: Overview; c: Copy; action: Action; fmt: (value: string) => string; setStatus: SetStatus }) { +function MonitorTab({ data, c, locale, action, fmt, setStatus }: { data: Overview; c: Copy; locale: Locale; action: Action; fmt: (value: string) => string; setStatus: SetStatus }) { const [monitor, setMonitor] = useState(null); const [url, setUrl] = useState(''); - const load = useCallback(() => fetch('/backend/api/super/monitor').then((response) => response.ok ? response.json() : null).then((value) => { if (value) setMonitor(value); }).catch(() => null), []); + const load = useCallback(() => fetch(`/backend/api/super/monitor?locale=${locale}`).then((response) => response.ok ? response.json() : null).then((value) => { if (value) setMonitor(value); }).catch(() => null), []); useEffect(() => { void load(); @@ -688,8 +688,8 @@ function MonitorTab({ data, c, action, fmt, setStatus }: { data: Overview; c: Co - - + +
{c.tabs.monitor}

{c.monitor.events}

{c.monitor.eventsText}

@@ -729,9 +729,9 @@ type AlertSettings = { type Incident = { id: string; key: string; category: string; severity: string; title: string; description: string | null; openedAt: string; resolvedAt: string | null; acknowledgedAt: string | null; acknowledgedBy: string | null; reminders: number }; type AlertsData = { settings: AlertSettings; incidents: { open: Incident[]; recent: Incident[] }; mentionAvailable: boolean }; -function useAlerts() { +function useAlerts(locale: Locale) { const [alerts, setAlerts] = useState(null); - const load = useCallback(() => fetch('/backend/api/super/alerts').then((response) => response.ok ? response.json() : null).then((value) => { if (value) setAlerts(value); }).catch(() => null), []); + const load = useCallback(() => fetch(`/backend/api/super/alerts?locale=${locale}`).then((response) => response.ok ? response.json() : null).then((value) => { if (value) setAlerts(value); }).catch(() => null), [locale]); useEffect(() => { void load(); }, [load]); return { alerts, load }; } @@ -767,8 +767,8 @@ function ChannelPicker({ data, c, guildId, channelId, onChange }: { data: Overvi ); } -function IncidentsCard({ c, action, fmt }: { c: Copy; action: Action; fmt: (value: string) => string }) { - const { alerts, load } = useAlerts(); +function IncidentsCard({ c, locale, action, fmt }: { c: Copy; locale: Locale; action: Action; fmt: (value: string) => string }) { + const { alerts, load } = useAlerts(locale); useEffect(() => { const timer = window.setInterval(() => void load(), 30_000); return () => window.clearInterval(timer); @@ -805,8 +805,8 @@ function IncidentsCard({ c, action, fmt }: { c: Copy; action: Action; fmt: (valu ); } -function RoutingCard({ data, c, action }: { data: Overview; c: Copy; action: Action }) { - const { alerts } = useAlerts(); +function RoutingCard({ data, c, locale, action }: { data: Overview; c: Copy; locale: Locale; action: Action }) { + const { alerts } = useAlerts(locale); const [draft, setDraft] = useState(null); useEffect(() => { if (alerts && !draft) setDraft(alerts.settings); }, [alerts, draft]); if (!draft) return null; diff --git a/packages/db/prisma/migrations/20261021180000_localized_alerts/migration.sql b/packages/db/prisma/migrations/20261021180000_localized_alerts/migration.sql new file mode 100644 index 0000000..773ae6e --- /dev/null +++ b/packages/db/prisma/migrations/20261021180000_localized_alerts/migration.sql @@ -0,0 +1,5 @@ +-- Alerts are rendered in the language of the server they are posted to: +-- store a message key (and parameters) instead of a fixed Italian text. +ALTER TABLE "InstanceEvent" ADD COLUMN "messageKey" TEXT; +ALTER TABLE "Incident" ADD COLUMN "messageKey" TEXT; +ALTER TABLE "Incident" ADD COLUMN "params" JSONB NOT NULL DEFAULT '{}'; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 6af9795..b151794 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -291,6 +291,9 @@ model InstanceEvent { // Routing: category (where it goes) and severity (how loudly). category String @default("errors") severity String @default("info") + // Text key and parameters (in details): rendered in the language of the + // server the alert is posted to. Older rows only have a fixed title. + messageKey String? title String details Json // pending → sent | digest → digested | log | failed @@ -311,6 +314,8 @@ model Incident { severity String title String description String? + messageKey String? + params Json @default("{}") openedAt DateTime @default(now()) resolvedAt DateTime? acknowledgedAt DateTime? diff --git a/packages/shared/src/alert-messages.ts b/packages/shared/src/alert-messages.ts new file mode 100644 index 0000000..8299261 --- /dev/null +++ b/packages/shared/src/alert-messages.ts @@ -0,0 +1,207 @@ +import type { AlertCategory } from './alerts.js'; + +/** + * Texts of instance alerts in English and Italian. Alerts are stored as a key + * plus parameters and rendered when they are delivered, in the language of + * the server they are posted to (its "message language" setting). + */ + +export type AlertLocale = 'en' | 'it'; +export type AlertParams = Record; + +export const normalizeAlertLocale = (value: unknown): AlertLocale => (typeof value === 'string' && value.toLowerCase().startsWith('it') ? 'it' : 'en'); + +const num = (value: unknown, locale: AlertLocale) => Number(value ?? 0).toLocaleString(locale === 'it' ? 'it-IT' : 'en-US'); +const text = (value: unknown, fallback = '—') => (value === null || value === undefined || value === '' ? fallback : String(value)); + +type Message = { + title: Record; + description?: Record string>; +}; + +const MESSAGES = { + 'install.rejected': { + title: { en: 'Installation rejected', it: 'Installazione rifiutata' }, + description: { + en: (p) => `Sentinel left **${text(p.guild)}**: ${p.reason === 'blocked-installer' ? 'the person who added the bot is blacklisted' : 'the server is blacklisted'}.`, + it: (p) => `Sentinel è uscito da **${text(p.guild)}**: ${p.reason === 'blocked-installer' ? 'chi ha aggiunto il bot è in blacklist' : 'il server è in blacklist'}.` + } + }, + 'guild.added': { title: { en: 'Sentinel added to a server', it: 'Sentinel aggiunto a un server' } }, + 'guild.removed': { title: { en: 'Sentinel removed from a server', it: 'Sentinel rimosso da un server' } }, + 'quota.reached': { + title: { en: 'Daily quota reached', it: 'Quota giornaliera raggiunta' }, + description: { + en: (p) => `**${text(p.guild)}** went over ${num(p.limit, 'en')} events today (plan ${text(p.plan)}): further events are not recorded until midnight UTC.`, + it: (p) => `**${text(p.guild)}** ha superato ${num(p.limit, 'it')} eventi oggi (piano ${text(p.plan)}): gli eventi successivi non vengono registrati fino a mezzanotte UTC.` + } + }, + 'bot.online': { + title: { en: 'Sentinel online', it: 'Sentinel online' }, + description: { en: (p) => `Connected to Discord as **${text(p.user)}**.`, it: (p) => `Connesso a Discord come **${text(p.user)}**.` } + }, + 'guilds.purged': { + title: { en: 'Data of removed servers deleted', it: 'Dati di server rimossi cancellati' }, + description: { + en: (p) => `Deleted the data of ${num(p.count, 'en')} servers Sentinel was removed from more than ${num(p.days, 'en')} days ago.`, + it: (p) => `Cancellati i dati di ${num(p.count, 'it')} server da cui Sentinel è stato rimosso da più di ${num(p.days, 'it')} giorni.` + } + }, + 'client.warning': { title: { en: 'Discord client warning', it: 'Warning del client Discord' }, description: { en: (p) => text(p.detail), it: (p) => text(p.detail) } }, + 'client.error': { title: { en: 'Discord client error', it: 'Errore del client Discord' }, description: { en: (p) => text(p.detail), it: (p) => text(p.detail) } }, + 'bot.unhandled': { title: { en: 'Unhandled error in the bot', it: 'Errore non gestito nel bot' }, description: { en: (p) => text(p.detail), it: (p) => text(p.detail) } }, + 'shard.resumed': { + title: { en: 'Connection to Discord restored', it: 'Connessione a Discord ripristinata' }, + description: { en: (p) => `Shard ${text(p.shard)}: ${num(p.replayed, 'en')} events recovered.`, it: (p) => `Shard ${text(p.shard)}: ${num(p.replayed, 'it')} eventi recuperati.` } + }, + 'policy.blocked': { + title: { en: 'Operation blocked by the security policy', it: 'Operazione bloccata dalla politica di sicurezza' }, + description: { + en: (p) => `The bot attempted a call that is not allowed and the policy blocked it: \`${text(p.method)} ${text(p.route)}\`.`, + it: (p) => `Il bot ha tentato una chiamata non consentita e la politica l’ha bloccata: \`${text(p.method)} ${text(p.route)}\`.` + } + }, + 'status.test': { + title: { en: 'Status notifications active', it: 'Notifiche di stato attive' }, + description: { + en: () => 'Test message from the super console. Alerts routed to this channel will arrive here.', + it: () => 'Messaggio di prova dalla super console. Qui arriveranno gli allarmi instradati verso questo canale.' + } + }, + 'bot.down': { + title: { en: 'Bot unreachable', it: 'Bot non raggiungibile' }, + description: { + en: (p) => p.mode === 'disconnected' + ? 'The bot process answers but is not connected to Discord: logs are not being recorded.' + : `The bot has not answered for about ${num(p.minutes, 'en')} minutes${p.sinceStart ? ' (since the API started)' : ''}: logs are not being recorded.`, + it: (p) => p.mode === 'disconnected' + ? 'Il processo del bot risponde ma non è connesso a Discord: i log non vengono registrati.' + : `Il bot non risponde da circa ${num(p.minutes, 'it')} minuti${p.sinceStart ? ' (dall’avvio dell’API)' : ''}: i log non vengono registrati.` + } + }, + 'bot.restored': { + title: { en: 'Bot restored', it: 'Bot ripristinato' }, + description: { + en: (p) => `Sentinel is connected to Discord again after ${durationText(p.durationMs, 'en')} of downtime (${num(p.guilds, 'en')} servers).`, + it: (p) => `Sentinel è di nuovo connesso a Discord dopo ${durationText(p.durationMs, 'it')} di interruzione (${num(p.guilds, 'it')} server).` + } + }, + 'sync.stopped': { + title: { en: 'Discord subscription sync stopped', it: 'Sincronizzazione abbonamenti Discord ferma' }, + description: { + en: () => 'Active subscriptions cannot be read from Discord: new purchases, renewals and cancellations do not reach the server plans.', + it: () => 'Gli abbonamenti attivi non si possono leggere da Discord: nuovi acquisti, rinnovi e disdette non arrivano ai piani dei server.' + } + }, + 'sync.restored': { + title: { en: 'Subscription sync restored', it: 'Sincronizzazione abbonamenti ripristinata' }, + description: { + en: (p) => `Discord subscriptions reach the plans again after ${durationText(p.durationMs, 'en')}.`, + it: (p) => `Gli abbonamenti Discord tornano ad arrivare ai piani dopo ${durationText(p.durationMs, 'it')}.` + } + }, + 'delivery.backlog': { + title: { en: 'Log queue backing up', it: 'Coda dei log in accumulo' }, + description: { + en: (p) => `${num(p.count, 'en')} logs have been waiting for delivery for more than 10 minutes: the bot's dispatcher is stopped or slow.`, + it: (p) => `${num(p.count, 'it')} log attendono la consegna da più di 10 minuti: il dispatcher del bot è fermo o rallentato.` + } + }, + 'delivery.backlogCleared': { + title: { en: 'Log queue cleared', it: 'Coda dei log smaltita' }, + description: { en: (p) => `Log delivery is back to normal after ${durationText(p.durationMs, 'en')}.`, it: (p) => `La consegna dei log è tornata regolare dopo ${durationText(p.durationMs, 'it')}.` } + }, + 'delivery.failures': { + title: { en: 'Many log deliveries failed', it: 'Molti invii dei log falliti' }, + description: { en: (p) => `${num(p.count, 'en')} logs rejected by Discord in the last 15 minutes.`, it: (p) => `${num(p.count, 'it')} log rifiutati da Discord negli ultimi 15 minuti.` } + }, + 'delivery.failuresCleared': { + title: { en: 'Log deliveries back to normal', it: 'Invii dei log tornati regolari' }, + description: { en: (p) => `Failed deliveries went back down after ${durationText(p.durationMs, 'en')}.`, it: (p) => `Gli invii falliti sono rientrati dopo ${durationText(p.durationMs, 'it')}.` } + }, + 'subscription.new': { + title: { en: 'New Discord subscription', it: 'Nuovo abbonamento Discord' }, + description: { en: (p) => `The server now has the ${text(p.plan)} plan.`, it: (p) => `Il server ora ha il piano ${text(p.plan)}.` } + }, + 'subscription.changed': { + title: { en: 'Discord subscription changed', it: 'Abbonamento Discord cambiato' }, + description: { en: (p) => `The server now has the ${text(p.plan)} plan.`, it: (p) => `Il server ora ha il piano ${text(p.plan)}.` } + }, + 'subscription.ended': { + title: { en: 'Discord subscription ended', it: 'Abbonamento Discord terminato' }, + description: { en: () => 'The server is back on the Free plan.', it: () => 'Il server è tornato al piano Free.' } + }, + 'webhook.test': { + title: { en: 'Alarms active', it: 'Allarmi attivi' }, + description: { + en: () => 'Test message from the super console: the alert and the reminders about the bot being unreachable, and its recovery, will arrive here.', + it: () => 'Messaggio di prova dalla super console: qui arriveranno l’avviso e i promemoria quando il bot non è raggiungibile, e la notizia del ripristino.' + } + } +} satisfies Record; + +export type AlertMessageKey = keyof typeof MESSAGES; +export const isAlertMessageKey = (value: unknown): value is AlertMessageKey => typeof value === 'string' && value in MESSAGES; + +export function renderAlertMessage(key: AlertMessageKey, params: AlertParams, locale: AlertLocale) { + const message: Message = MESSAGES[key]; + return { title: message.title[locale], description: message.description ? message.description[locale](params) : null }; +} + +const FIELDS = { + server: { en: 'Server', it: 'Server' }, + servers: { en: 'Servers', it: 'Server' }, + members: { en: 'Members', it: 'Membri' }, + addedBy: { en: 'Added by', it: 'Aggiunto da' }, + startedAt: { en: 'Started', it: 'Avvio' } +} satisfies Record>; + +export type AlertFieldKey = keyof typeof FIELDS; +export type AlertField = { key: AlertFieldKey; value: string }; +export const alertFieldLabel = (key: string, locale: AlertLocale) => (FIELDS as Record>)[key]?.[locale] ?? key; + +const CATEGORIES: Record> = { + availability: { en: 'Availability', it: 'Disponibilità' }, + security: { en: 'Security', it: 'Sicurezza' }, + errors: { en: 'Errors', it: 'Errori' }, + delivery: { en: 'Log delivery', it: 'Consegna dei log' }, + billing: { en: 'Subscriptions', it: 'Abbonamenti' }, + installations: { en: 'Installations', it: 'Installazioni' }, + usage: { en: 'Usage', it: 'Utilizzo' } +}; +export const alertCategoryLabel = (category: AlertCategory, locale: AlertLocale) => CATEGORIES[category][locale]; + +export function durationText(ms: unknown, locale: AlertLocale) { + const minutes = Math.max(1, Math.round(Number(ms ?? 0) / 60_000)); + if (minutes < 60) return `${minutes} min`; + const hours = Math.floor(minutes / 60); + if (hours < 48) return `${hours} h ${minutes % 60} min`; + const days = Math.floor(hours / 24); + return locale === 'it' ? `${days} giorni` : `${days} days`; +} + +/** Texts added by the router around the alerts themselves. */ +export const ALERT_UI = { + en: { + footer: (category: string) => `Sentinel · ${category}`, + repeated: (count: number, since: string) => `${count} identical alerts since ${since}; the latest is shown above.`, + digestTitle: 'Daily digest', + digestText: (count: number) => `${count} informational alerts since the last digest. Details are in the super console → Monitoring.`, + digestFooter: 'Sentinel · digest', + reminder: (title: string) => `Reminder: ${title}`, + reminderText: (duration: string) => `Still open after ${duration}. Acknowledge it in the super console → Monitoring to stop the reminders.`, + monitorFooter: 'Sentinel · monitoring', + statusFooter: 'Sentinel · bot status' + }, + it: { + footer: (category: string) => `Sentinel · ${category}`, + repeated: (count: number, since: string) => `${count} avvisi uguali dalle ${since}; qui sopra il più recente.`, + digestTitle: 'Riepilogo giornaliero', + digestText: (count: number) => `${count} avvisi informativi dall’ultimo riepilogo. I dettagli sono nella super console → Monitoraggio.`, + digestFooter: 'Sentinel · riepilogo', + reminder: (title: string) => `Promemoria: ${title}`, + reminderText: (duration: string) => `Ancora aperto da ${duration}. Segnalo come preso in carico nella super console → Monitoraggio per non ricevere altri promemoria.`, + monitorFooter: 'Sentinel · monitoraggio', + statusFooter: 'Sentinel · stato del bot' + } +} as const; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1cbbd15..7868ac9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -84,3 +84,4 @@ export const eventDefinition = (key: string) => EVENT_CATALOG.find((event) => ev export * from './plans.js'; export * from './log-buffer.js'; export * from './alerts.js'; +export * from './alert-messages.js';