Files
Nexumi/apps/bot/src/health.ts
smueller ffaa1e26fd Enhance bot functionality with new features and improvements
- Updated package dependencies to include `@sentry/node` for error tracking.
- Refactored command routing to incorporate user and guild blacklisting checks, improving command execution safety.
- Enhanced health server metrics to include queue waiting times, providing better insights into system performance.
- Introduced confirmation handling for guild backup commands, streamlining user interactions.
- Improved moderation commands with better error handling and case management, ensuring robust moderation capabilities.
- Added new duration parsing functions to handle timeout durations effectively, enhancing moderation features.
- Updated localization files to reflect new command structures and user guidance improvements.
2026-07-23 09:53:44 +02:00

176 lines
5.5 KiB
TypeScript

import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { env } from './env.js';
import { redis } from './redis.js';
import {
deleteCaptchaChallenge,
getCaptchaChallenge,
hashCaptchaAnswer
} from './modules/verification/service.js';
import {
automodQueue,
backupQueue,
birthdayQueue,
feedsQueue,
presenceQueue,
retentionQueue,
ticketQueue,
verificationQueue
} from './queues.js';
import { collectMetricsSnapshot, formatPrometheus } from './metrics.js';
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
req.on('error', reject);
});
}
function parseFormBody(body: string): Record<string, string> {
return Object.fromEntries(new URLSearchParams(body));
}
function renderCaptchaPage(token: string, question: string, error?: string): string {
const errorBlock = error ? `<p style="color:#ef4444">${error}</p>` : '';
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Nexumi Verification</title>
<style>
body { font-family: Inter, sans-serif; background:#111827; color:#f9fafb; display:flex; justify-content:center; align-items:center; min-height:100vh; margin:0; }
form { background:#1f2937; padding:2rem; border-radius:12px; width:min(420px, 90vw); }
input, button { width:100%; padding:0.75rem; margin-top:0.75rem; border-radius:8px; border:1px solid #374151; background:#111827; color:#f9fafb; }
button { background:#6366f1; border:none; cursor:pointer; font-weight:600; }
</style>
</head>
<body>
<form method="POST" action="/verify/captcha">
<h1>Nexumi Verification</h1>
<p>Solve: <strong>${question} = ?</strong></p>
${errorBlock}
<input type="hidden" name="token" value="${token}" />
<input type="number" name="answer" required autofocus />
<button type="submit">Verify</button>
</form>
</body>
</html>`;
}
async function handleCaptchaGet(url: URL, res: ServerResponse): Promise<void> {
const token = url.searchParams.get('token');
if (!token) {
res.statusCode = 400;
res.end('Missing token');
return;
}
const challenge = await getCaptchaChallenge(redis, token);
if (!challenge) {
res.statusCode = 404;
res.end('Captcha expired');
return;
}
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(renderCaptchaPage(token, challenge.question));
}
async function handleCaptchaPost(req: IncomingMessage, res: ServerResponse): Promise<void> {
const body = await readBody(req);
const form = parseFormBody(body);
const token = form.token;
const answer = form.answer;
if (!token || !answer) {
res.statusCode = 400;
res.end('Missing fields');
return;
}
const challenge = await getCaptchaChallenge(redis, token);
if (!challenge) {
res.statusCode = 404;
res.end('Captcha expired');
return;
}
if (hashCaptchaAnswer(answer) !== challenge.answerHash) {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(renderCaptchaPage(token, challenge.question, 'Wrong answer. Try again.'));
return;
}
await deleteCaptchaChallenge(redis, token);
await verificationQueue.add(
'verificationComplete',
{ guildId: challenge.guildId, userId: challenge.userId },
{ removeOnComplete: 1000, removeOnFail: 500 }
);
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end('<html><body style="font-family:sans-serif;background:#111827;color:#f9fafb;display:flex;justify-content:center;align-items:center;min-height:100vh"><div><h1>Verified</h1><p>You can return to Discord.</p></div></body></html>');
}
async function approximateQueueWaiting(): Promise<number> {
const counts = await Promise.all([
automodQueue.getWaitingCount(),
backupQueue.getWaitingCount(),
birthdayQueue.getWaitingCount(),
feedsQueue.getWaitingCount(),
presenceQueue.getWaitingCount(),
retentionQueue.getWaitingCount(),
ticketQueue.getWaitingCount()
]);
return counts.reduce((sum, n) => sum + n, 0);
}
export function startHealthServer(): void {
const server = createServer(async (req, res) => {
if (!req.url) {
res.statusCode = 400;
res.end('Bad Request');
return;
}
const url = new URL(req.url, `http://127.0.0.1:${env.HEALTH_PORT}`);
if (url.pathname === '/health') {
res.statusCode = 200;
res.end('ok');
return;
}
if (url.pathname === '/metrics') {
const auth = req.headers.authorization;
if (!env.METRICS_TOKEN || auth !== `Bearer ${env.METRICS_TOKEN}`) {
res.statusCode = 401;
res.end('Unauthorized');
return;
}
try {
const snapshot = await collectMetricsSnapshot(redis);
snapshot.queueWaiting = await approximateQueueWaiting();
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
res.end(formatPrometheus(snapshot));
} catch {
res.statusCode = 500;
res.end('metrics error');
}
return;
}
if (url.pathname === '/verify/captcha' && req.method === 'GET') {
await handleCaptchaGet(url, res);
return;
}
if (url.pathname === '/verify/captcha' && req.method === 'POST') {
await handleCaptchaPost(req, res);
return;
}
res.statusCode = 404;
res.end('Not Found');
});
server.listen(env.HEALTH_PORT);
}