feat: Integrationen takten und Telegram als dritten Meldeweg

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 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-11 23:04:05 +02:00
co-authored by Claude Opus 5
parent 6dad3331e2
commit bc60e5f864
3 changed files with 123 additions and 9 deletions
+11 -1
View File
@@ -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,
+48 -8
View File
@@ -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<BenutzerMitEinstellung[]> {
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<Meldung[]> {
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<Record<Quelle, string>> = {
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 }
+64
View File
@@ -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<void> {
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) => {