From bc60e5f864209e1f729773688ac0a0f743685c9e Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 11 Sep 2026 23:04:05 +0200 Subject: [PATCH] feat: Integrationen takten und Telegram als dritten Meldeweg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram alle zwei Minuten, Zammad und Spiegel alle fuenfzehn. Wer dem Bot etwas schickt, erwartet die Aufgabe gleich in der Liste; in Zammad aendert sich dagegen nichts im Minutentakt. Meldungen gehen jetzt auch per Telegram hinaus — mit einem Knopf zum Abhaken, wenn es um genau eine Aufgabe geht. Eine klemmende Integration meldet sich wie ein klemmender Kalender: erst nach dem dritten Fehlversuch, dann aber deutlich. Co-Authored-By: Claude Opus 5 --- src/lib/benachrichtigung.ts | 12 ++++- src/server/benachrichtigung/versand.ts | 56 ++++++++++++++++++---- src/server/jobs.ts | 64 ++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 9 deletions(-) diff --git a/src/lib/benachrichtigung.ts b/src/lib/benachrichtigung.ts index 0fbb16b..ab756b5 100644 --- a/src/lib/benachrichtigung.ts +++ b/src/lib/benachrichtigung.ts @@ -47,6 +47,7 @@ export type Einstellung = { anlaesse: readonly Anlass[] perPush: boolean perMail: boolean + perTelegram: boolean /** Uhrzeit des Tagesüberblicks, Minuten seit Mitternacht. */ ueberblickUm: number /** Ruhezeit: von hier bis … nichts melden. */ @@ -68,6 +69,7 @@ export const VOREINSTELLUNG: Einstellung = { anlaesse: ['tagesueberblick', 'faelligkeit'], perPush: false, perMail: false, + perTelegram: false, ueberblickUm: 7 * 60, ruheVon: 20 * 60, ruheBis: 7 * 60, @@ -103,6 +105,13 @@ export type Meldung = { text: string /** Wohin der Klick führt. */ pfad: string + /** + * Die Aufgabe, um die es geht — sofern es genau eine ist. + * + * Nur dann lässt sich unter die Meldung ein Knopf hängen, der sie abhakt. + * Ein Tagesüberblick hat keine. + */ + objektId?: string } /** @@ -124,7 +133,7 @@ export function sendenEntscheiden( if (!einstellung.anlaesse.includes(meldung.anlass)) { return { senden: false, grund: 'abgeschaltet' } } - if (!einstellung.perPush && !einstellung.perMail) { + if (!einstellung.perPush && !einstellung.perMail && !einstellung.perTelegram) { return { senden: false, grund: 'kein-weg' } } if (bereitsGemeldet.has(meldung.schluessel)) { @@ -202,6 +211,7 @@ export const einstellungSchema = z.object({ anlaesse: z.array(z.enum(ANLAESSE)).default([]), perPush: z.coerce.boolean().default(false), perMail: z.coerce.boolean().default(false), + perTelegram: z.coerce.boolean().default(false), ueberblickUm: uhrzeit, ruheVon: uhrzeit, ruheBis: uhrzeit, diff --git a/src/server/benachrichtigung/versand.ts b/src/server/benachrichtigung/versand.ts index c3be9ee..dc97ccd 100644 --- a/src/server/benachrichtigung/versand.ts +++ b/src/server/benachrichtigung/versand.ts @@ -17,9 +17,13 @@ import { tagesBeginn, tagesEnde, } from '@/lib/zeit' +import type { Quelle } from '@/generated/prisma/enums' +import { INTEGRATION_FEHLER_AB_VERSUCH } from '@/lib/integration' import { db } from '@/server/db' import { mailSenden } from '@/server/benachrichtigung/mail' import { pushSenden } from '@/server/benachrichtigung/push' +import { telegramMeldung } from '@/server/integration/telegram' +import { umgebung } from '@/server/umgebung' import { jetzt } from '@/server/zeitgeber' /** @@ -46,6 +50,7 @@ export function alsEinstellung( anlaesse: string perPush: boolean perMail: boolean + perTelegram: boolean ueberblickUm: number ruheVon: number ruheBis: number @@ -66,6 +71,7 @@ export function alsEinstellung( anlaesse, perPush: zeile.perPush, perMail: zeile.perMail, + perTelegram: zeile.perTelegram, ueberblickUm: zeile.ueberblickUm, ruheVon: zeile.ruheVon, ruheBis: zeile.ruheBis, @@ -86,6 +92,7 @@ async function empfaengerLaden(): Promise { anlaesse: true, perPush: true, perMail: true, + perTelegram: true, ueberblickUm: true, ruheVon: true, ruheBis: true, @@ -141,6 +148,9 @@ export async function meldungZustellen( if (benutzer.einstellung.perPush) await pushSenden(benutzer.id, meldung) if (benutzer.einstellung.perMail) await mailSenden(benutzer.email, meldung) + if (benutzer.einstellung.perTelegram && umgebung().telegramAktiv) { + await telegramMeldung(benutzer.id, meldung) + } return true } @@ -232,6 +242,8 @@ async function faelligkeitenBauen(zeitpunkt: Date): Promise { titel: 'Heute fällig', text: aufgabe.titel, pfad: '/aufgaben', + // Genau eine Aufgabe: Telegram kann einen Knopf zum Abhaken anbieten. + objektId: aufgabe.id, })) } @@ -266,7 +278,15 @@ async function zeitblockErinnerungenBauen( })) } -/** Kalender, die mehrfach hintereinander nicht abgleichbar waren. */ +/** Lesbare Namen für die Meldung; die Aufzählung selbst ist technisch. */ +const INTEGRATION_NAME: Partial> = { + ZAMMAD: 'Zammad', + WARTUNGSPLANER: 'Wartungsplaner', + TELEGRAM: 'Telegram', + SPIEGEL: 'Spiegel', +} + +/** Kalender und Integrationen, die mehrfach hintereinander klemmten. */ async function abgleichfehlerBauen( benutzerId: string, zeitpunkt: Date, @@ -283,14 +303,34 @@ async function abgleichfehlerBauen( select: { id: true, anzeigename: true, letzterFehler: true }, }) + // Eine klemmende Integration wiegt genauso schwer wie ein klemmender + // Kalender: In beiden Fällen steht die Liste auf einem alten Stand, ohne + // dass es jemandem auffiele. + const integrationen = await db.integrationszustand.findMany({ + where: { + letzterFehler: { not: null }, + fehlversuche: { gte: INTEGRATION_FEHLER_AB_VERSUCH }, + }, + select: { quelle: true, letzterFehler: true }, + }) + const tag = alsTagesschluessel(zeitpunkt) - return konten.map((konto) => ({ - anlass: 'abgleichfehler' as const, - schluessel: meldungsSchluessel('abgleichfehler', konto.id, tag), - titel: 'Ein Kalender lässt sich nicht abgleichen', - text: `„${konto.anzeigename}": ${konto.letzterFehler ?? 'unbekannter Fehler'}`, - pfad: '/kalenderkonten', - })) + return [ + ...konten.map((konto) => ({ + anlass: 'abgleichfehler' as const, + schluessel: meldungsSchluessel('abgleichfehler', konto.id, tag), + titel: 'Ein Kalender lässt sich nicht abgleichen', + text: `„${konto.anzeigename}": ${konto.letzterFehler ?? 'unbekannter Fehler'}`, + pfad: '/kalenderkonten', + })), + ...integrationen.map((i) => ({ + anlass: 'abgleichfehler' as const, + schluessel: meldungsSchluessel('abgleichfehler', i.quelle, tag), + titel: 'Eine Integration klemmt', + text: `${INTEGRATION_NAME[i.quelle] ?? i.quelle}: ${i.letzterFehler ?? 'unbekannter Fehler'}`, + pfad: '/integrationen', + })), + ] } export type VersandBericht = { geprueft: number; gesendet: number } diff --git a/src/server/jobs.ts b/src/server/jobs.ts index 62a33ec..93196a5 100644 --- a/src/server/jobs.ts +++ b/src/server/jobs.ts @@ -7,6 +7,10 @@ import { alteNachweiseEntfernen, benachrichtigungenVerschicken, } from '@/server/benachrichtigung/versand' +import { zammadAbgleichen } from '@/server/integration/zammad' +import { alteKopplungenEntfernen, telegramAbholen } from '@/server/integration/telegram' +import { spiegelSenden } from '@/server/integration/spiegel' +import { umgebung } from '@/server/umgebung' import { jetzt } from '@/server/zeitgeber' /** @@ -31,6 +35,24 @@ const AUFRAEUM_PLAN = '17 3 * * *' * dank Wiederholungssperre nur einmal gemeldet. */ const MELDUNGS_PLAN = '*/10 * * * *' +/** + * Integrationen laufen im eigenen Takt. + * + * Telegram oefter als der Rest: Wer dem Bot etwas schickt, erwartet die + * Aufgabe gleich in der Liste, nicht in einer Viertelstunde. Zammad und der + * Spiegel duerfen traeger sein — dort aendert sich nichts im Minutentakt. + */ +const TELEGRAM_PLAN = '*/2 * * * *' +const ZAMMAD_PLAN = '*/15 * * * *' +const SPIEGEL_PLAN = '*/15 * * * *' + +/** + * Wer im Protokoll steht, wenn niemand geklickt hat. + * + * Ein Job hat keinen Benutzer. Statt den Eintrag wegzulassen, traegt er + * sichtbar den Dienst ein — sonst sieht es aus, als sei nichts passiert. + */ +const DIENST = { id: null, email: 'dienst@intern' } async function alleKontenAbgleichen(): Promise { const zeitpunkt = jetzt() @@ -87,11 +109,53 @@ export function jobsStarten(): void { }) }) + const einst = umgebung() + + if (einst.zammadAktiv) { + cron.schedule(ZAMMAD_PLAN, () => { + void zammadAbgleichen(DIENST) + .then((bericht) => { + if (bericht.angelegt > 0 || bericht.geschlossen > 0) { + console.warn( + `Zammad: ${bericht.angelegt} neu, ${bericht.geschlossen} geschlossen.`, + ) + } + }) + .catch((fehler: unknown) => { + console.error('Zammad-Abgleich abgebrochen:', fehler) + }) + }) + } + + if (einst.telegramAktiv) { + cron.schedule(TELEGRAM_PLAN, () => { + void telegramAbholen(DIENST) + .then((bericht) => { + if (bericht.verworfen > 0) { + // Nie die Chatkennung: Sie gehoert zu einer Person. + console.warn(`Telegram: ${bericht.verworfen} Nachricht(en) verworfen.`) + } + }) + .catch((fehler: unknown) => { + console.error('Telegram-Abholen abgebrochen:', fehler) + }) + }) + } + + if (einst.spiegelSendenAktiv) { + cron.schedule(SPIEGEL_PLAN, () => { + void spiegelSenden().catch((fehler: unknown) => { + console.error('Spiegel-Versand abgebrochen:', fehler) + }) + }) + } + cron.schedule(AUFRAEUM_PLAN, () => { void Promise.all([ alteTermineEntfernen(), abgelaufeneChallengesEntfernen(jetzt()), alteNachweiseEntfernen(jetzt()), + alteKopplungenEntfernen(jetzt()), ]) .then(() => undefined) .catch((fehler: unknown) => {