Mit gesetzter Ticket-Kategorie bekommt jedes offene Ticket einen eigenen Kanal, sichtbar nur für die Support-Rollen, den Bot und die per Discord verknüpften Mitglieder des Kunden. Ein minütlicher Abgleich im Worker legt fehlende Kanäle an (auch für Tickets aus Mail-Eingang/Vertragsverlängerung) und entfernt Kanäle geschlossener Tickets. Ticket-Meldungen gehen in einen eigenen Support-Kanal (#ticket-log, wird automatisch angelegt) statt in den Systemmeldungs-Kanal. Ohne Kategorie bleibt das bisherige Verhalten (private Threads) unverändert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
90 lines
6.2 KiB
TypeScript
90 lines
6.2 KiB
TypeScript
import http from 'node:http';
|
|
import { env } from './env.js';
|
|
import { pool } from '@kc/platform/db';
|
|
import { discordEnabled, discordReady, reconcileTicketChannels, syncDiscord, stopDiscord } from './discord.js';
|
|
import { recoverStale, runOnce } from './jobs.js';
|
|
import { enqueue } from '@kc/platform/jobs';
|
|
import { processContractLifecycle, scheduleDueSyncs } from '@kc/connectors';
|
|
import { checkMailbox } from './mailbox.js';
|
|
|
|
const log = (m: string) => console.log(JSON.stringify({ t: new Date().toISOString(), svc: 'worker', msg: m }));
|
|
let lastLoop = Date.now();
|
|
let stopping = false;
|
|
|
|
// Health: Liveness (Schleife lebt) + Readiness (DB), Discord-Status nur informativ
|
|
http.createServer(async (req, res) => {
|
|
const alive = Date.now() - lastLoop < 60_000;
|
|
let db = false; try { await pool.query('SELECT 1'); db = true; } catch { /* db down */ }
|
|
const ok = alive && db;
|
|
res.writeHead(req.url === '/health' ? (alive ? 200 : 503) : (ok ? 200 : 503), { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify({ status: ok ? 'ok' : 'degraded', loop: alive, db, discord: discordEnabled() ? (discordReady() ? 'connected' : 'connecting') : 'disabled' }));
|
|
}).listen(env.healthPort, '127.0.0.1');
|
|
|
|
await recoverStale(log);
|
|
void syncDiscord(log);
|
|
setInterval(() => void syncDiscord(log), 60_000); // erkennt geänderte Einstellungen (Einstellungen > Discord) und verbindet bei Bedarf neu
|
|
setInterval(() => recoverStale(log).catch(() => undefined), 60_000);
|
|
// Discord-Ticketkanäle: offene Tickets bekommen einen Kanal, geschlossene verlieren ihn (nur im Kategorie-Modus)
|
|
setInterval(() => void reconcileTicketChannels(log).catch((e) => log(`Ticket-Kanäle: Abgleich fehlgeschlagen: ${(e as Error).message}`)), 60_000);
|
|
// Regelmäßiger Abgleich: fällige Connector-Instanzen als Aufträge einplanen (idempotent pro Zeitfenster)
|
|
const schedule = () => scheduleDueSyncs((t, p, o) => enqueue(t, p, o)).catch((e) => log(`Planung fehlgeschlagen: ${(e as Error).message}`));
|
|
setInterval(schedule, 30_000); void schedule();
|
|
// Verträge: Kündigungen wirksam machen, verlängern, auslaufen lassen (idempotent)
|
|
const lifecycle = () => processContractLifecycle(new Date(), (t, p, o) => enqueue(t, p, o)).then((r) => { if (r.ended || r.renewed) log(`Verträge: ${r.ended} beendet, ${r.renewed} verlängert`); }).catch((e) => log(`Vertragslauf fehlgeschlagen: ${(e as Error).message}`));
|
|
setInterval(lifecycle, 60_000); void lifecycle();
|
|
|
|
// Backup-Frische: fehlt ein erfolgreiches Backup seit >26 h, wird gemeldet (höchstens alle 12 h, ohne Nutzdaten)
|
|
import { readFile } from 'node:fs/promises';
|
|
const checkBackup = async () => {
|
|
try {
|
|
const st = JSON.parse(await readFile(process.env.BACKUP_STATUS_FILE ?? '/var/lib/kundencenter/backup-status.json', 'utf8'));
|
|
const age = st.lastRun?.at ? (Date.now() - new Date(st.lastRun.at).getTime()) / 3600000 : Infinity;
|
|
if (age > 26) await enqueue('discord.notify', { event: 'backup.stale', detail: Number.isFinite(age) ? `letzter Lauf vor ${Math.round(age)} Stunden` : 'noch nie gelaufen' }, { idempotencyKey: `backup.stale:${Math.floor(Date.now() / (12 * 3600000))}` });
|
|
} catch { /* Backup nicht eingerichtet: keine Meldung */ }
|
|
};
|
|
setInterval(() => void checkBackup(), 3600_000);
|
|
|
|
// Eigene Lizenz (licensing.flessinglabs.com): "beim Start prüfen, dann periodisch alle 6-24h" laut
|
|
// INTEGRATION.md (Rate-Limit 30/min erlaubt deutlich mehr, aber unnötig). checkLicenseNow() ist ein No-Op-
|
|
// Fehlschlag, solange kein Programm-Key/Lizenzschlüssel hinterlegt ist (siehe LicenseCheckError) - dann
|
|
// einfach weiter versuchen, keine Alarmierung (Betreiber hat die Lizenzierung schlicht noch nicht eingerichtet).
|
|
import { checkLicenseNow, LicenseCheckError } from '@kc/platform/license';
|
|
const checkLicense = async () => {
|
|
try { await checkLicenseNow(); }
|
|
catch (e) { if (!(e instanceof LicenseCheckError)) log(`Lizenzprüfung fehlgeschlagen: ${(e as Error).message}`); }
|
|
};
|
|
setInterval(() => void checkLicense(), 12 * 3600_000); void checkLicense();
|
|
|
|
// Vertrags-Erinnerung: ~30 Tage vor Laufzeitende Mail mit "Behalten"/"Kündigen"-Link (einmal je Vertrag,
|
|
// markiert über renewal_reminder_sent_at). Unabhängig von processContractLifecycle oben, die nur zum
|
|
// tatsächlichen Laufzeitende selbst verlängert/beendet.
|
|
import { sendRenewalReminders } from '@kc/platform/contractRenewal';
|
|
const checkRenewals = async () => {
|
|
try { const r = await sendRenewalReminders(); if (r.sent || r.skipped) log(`Vertrags-Erinnerungen: ${r.sent} gesendet, ${r.skipped} übersprungen`); }
|
|
catch (e) { log(`Vertrags-Erinnerung fehlgeschlagen: ${(e as Error).message}`); }
|
|
};
|
|
setInterval(() => void checkRenewals(), 24 * 3600_000); void checkRenewals();
|
|
|
|
// Audit-Aufbewahrung: Einträge jenseits der konfigurierten Frist löschen (Einstellungen > Firma/Audit; Default
|
|
// spiegelt den DSGVO-Grundsatz der Speicherbegrenzung, keine feste Gesetzeszahl). Idempotent, daher unproblematisch,
|
|
// mehrfach am Tag zu laufen; kein eigenes Datums-Gating nötig.
|
|
const purgeAudit = async () => {
|
|
try {
|
|
const [row] = (await pool.query('SELECT retention_days FROM audit_settings WHERE id = 1')) as any;
|
|
const { purgeAuditRetention } = await import('@kc/platform/audit');
|
|
const r = await purgeAuditRetention(row[0]?.retention_days ?? 180);
|
|
if (r.purged) log(`Audit-Aufbewahrung: ${r.purged} Einträge gelöscht (älter als ${r.retentionDays} Tage)`);
|
|
} catch (e) { log(`Audit-Aufbewahrung fehlgeschlagen: ${(e as Error).message}`); }
|
|
};
|
|
setInterval(() => void purgeAudit(), 24 * 3600_000); void purgeAudit();
|
|
|
|
// Ticket-Posteingang: eingehende Mails abrufen und zuordnen (siehe mailbox.ts)
|
|
setInterval(() => void checkMailbox(log).catch((e) => log(`IMAP-Lauf fehlgeschlagen: ${(e as Error).message}`)), 120_000);
|
|
void checkMailbox(log).catch((e) => log(`IMAP-Lauf fehlgeschlagen: ${(e as Error).message}`));
|
|
|
|
for (const sig of ['SIGTERM', 'SIGINT'] as const) process.on(sig, async () => { stopping = true; await stopDiscord(); await pool.end(); process.exit(0); });
|
|
log('Worker gestartet');
|
|
while (!stopping) {
|
|
try { lastLoop = Date.now(); const worked = await runOnce(log); if (!worked) await new Promise((r) => setTimeout(r, 2000)); }
|
|
catch (e) { log(`Schleifenfehler: ${(e as Error).message}`); await new Promise((r) => setTimeout(r, 5000)); }
|
|
}
|