feat: Zammad, Telegram und den Spiegel anbinden

Zammad liest ueber die Suche und schreibt nichts zurueck. Telegram holt
per getUpdates statt per Webhook — der Planer soll auch hinter einem
Anschluss ohne feste Adresse laufen. Der Spiegel schickt Belegungen ohne
Titel und nur die ausdruecklich markierten Aufgaben.

Wer dem Telegram-Bot schreibt, ohne im Adressbuch zu stehen, bekommt
keine Antwort. Die einzige Ausnahme ist ein gueltiger Kopplungscode: Er
ist der Beweis, dass jemand mit Zugang zur Anwendung diesen Chat will.
Ohne ihn muesste man die Chatkennung von Hand eintragen — und um sie zu
erfahren, muesste der Bot Unbekannten antworten.

Jede Sendung des Spiegels traegt eine Signatur ueber den Rumpf und gilt
fuenf Minuten. Der Empfaenger antwortet auf jeden Fehlschlag gleich:
falsches Geheimnis, verstellte Uhr oder Unsinn im Inhalt sind von aussen
nicht zu unterscheiden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-11 23:04:03 +02:00
co-authored by Claude Opus 5
parent e4d3547457
commit 6dad3331e2
5 changed files with 990 additions and 0 deletions
+197
View File
@@ -0,0 +1,197 @@
import 'server-only'
import type { Quelle } from '@/generated/prisma/enums'
import { uebernahmeEntscheiden, type Bestand, type Fund } from '@/lib/integration'
import { auditSchreiben } from '@/server/audit'
import { db } from '@/server/db'
import { jetzt } from '@/server/zeitgeber'
/**
* Die gemeinsame Aufnahmestelle für alle Integrationen.
*
* Jede Integration liefert eine Liste von Funden; was daraus wird, entscheidet
* für alle dieselbe Stelle. Damit verhält sich Zammad wie Telegram wie der
* Spiegel — und eine neue Integration muss nur noch abholen können.
*
* Hier hängt später auch der Wartungsplaner ein: Er braucht eine Funktion, die
* seine Daten in `Fund` übersetzt, sonst nichts.
*/
export type Aufnahmebericht = {
angelegt: number
aktualisiert: number
geschlossen: number
unveraendert: number
}
const LEER: Aufnahmebericht = {
angelegt: 0,
aktualisiert: 0,
geschlossen: 0,
unveraendert: 0,
}
/** Wer die Aufnahme angestoßen hat – für das Protokoll. */
export type Anstoss = { id: string | null; email: string }
/**
* Nimmt eine Runde Funde auf.
*
* Anschließend gilt: Zu jedem Fund gibt es genau eine Aufgabe mit
* `[quelle, ref]`, und keine Aufgabe wurde in Feldern angefasst, die hier
* gepflegt werden statt drüben.
*/
export async function fundeAufnehmen(
quelle: Quelle,
funde: readonly Fund[],
anstoss: Anstoss,
): Promise<Aufnahmebericht> {
const bericht = { ...LEER }
const zeitpunkt = jetzt()
for (const fund of funde) {
const vorhanden = await db.aufgabe.findUnique({
where: { quelle_quelleRef: { quelle, quelleRef: fund.ref } },
select: {
id: true,
titel: true,
notiz: true,
quelleUrl: true,
faelligAm: true,
prioritaet: true,
aufwandMinuten: true,
status: true,
},
})
const bestand: Bestand | null = vorhanden
? {
titel: vorhanden.titel,
notiz: vorhanden.notiz,
quelleUrl: vorhanden.quelleUrl,
faelligAm: vorhanden.faelligAm,
prioritaet: vorhanden.prioritaet,
aufwandMinuten: vorhanden.aufwandMinuten,
erledigt: vorhanden.status === 'ERLEDIGT' || vorhanden.status === 'VERWORFEN',
}
: null
const entscheidung = uebernahmeEntscheiden(fund, bestand)
if (entscheidung.tun === 'nichts') {
bericht.unveraendert++
continue
}
if (entscheidung.tun === 'anlegen') {
const angelegt = await db.aufgabe.create({
data: {
titel: fund.titel,
notiz: fund.notiz ?? null,
quelle,
quelleRef: fund.ref,
quelleUrl: fund.url ?? null,
faelligAm: fund.faelligAm ?? null,
...(fund.prioritaet ? { prioritaet: fund.prioritaet } : {}),
...(fund.aufwandMinuten ? { aufwandMinuten: fund.aufwandMinuten } : {}),
},
select: { id: true },
})
bericht.angelegt++
await auditSchreiben({
akteur: anstoss,
aktion: 'integration.angelegt',
objektTyp: 'Aufgabe',
objektId: angelegt.id,
// Der Titel steht schon in der Aufgabe; hier genügt die Herkunft.
details: { quelle, ref: fund.ref },
})
continue
}
if (entscheidung.tun === 'schliessen') {
await db.aufgabe.update({
where: { id: vorhanden?.id ?? '' },
data: { status: 'ERLEDIGT', erledigtAm: zeitpunkt },
})
bericht.geschlossen++
await auditSchreiben({
akteur: anstoss,
aktion: 'integration.geschlossen',
objektTyp: 'Aufgabe',
objektId: vorhanden?.id ?? null,
details: { quelle, ref: fund.ref },
})
continue
}
await db.aufgabe.update({
where: { id: vorhanden?.id ?? '' },
data: entscheidung.felder,
})
bericht.aktualisiert++
await auditSchreiben({
akteur: anstoss,
aktion: 'integration.aktualisiert',
objektTyp: 'Aufgabe',
objektId: vorhanden?.id ?? null,
details: { quelle, ref: fund.ref, felder: Object.keys(entscheidung.felder) },
})
}
return bericht
}
// --- Zustand je Integration ------------------------------------------------
/** Liest den Merker, an dem eine Integration weitermacht. */
export async function merkerLesen(quelle: Quelle): Promise<string | null> {
const zeile = await db.integrationszustand.findUnique({
where: { quelle },
select: { merker: true },
})
return zeile?.merker ?? null
}
/** Hält fest, dass ein Lauf geklappt hat. */
export async function laufGeglueckt(quelle: Quelle, merker?: string): Promise<void> {
const zeitpunkt = jetzt()
const werte = {
letzterLauf: zeitpunkt,
letzterErfolg: zeitpunkt,
letzterFehler: null,
fehlversuche: 0,
...(merker === undefined ? {} : { merker }),
}
await db.integrationszustand.upsert({
where: { quelle },
update: werte,
create: { quelle, ...werte },
})
}
/**
* Hält einen Fehlschlag fest und zählt hoch.
*
* Nur die Meldung, nie die Antwort des fremden Systems: Darin stehen gern
* Tokens, Kopfzeilen und ganze Datensätze.
*/
export async function laufGescheitert(quelle: Quelle, fehler: unknown): Promise<number> {
const meldung = fehler instanceof Error ? fehler.message : 'unbekannter Fehler'
const zeitpunkt = jetzt()
const zeile = await db.integrationszustand.upsert({
where: { quelle },
update: {
letzterLauf: zeitpunkt,
letzterFehler: meldung.slice(0, 500),
fehlversuche: { increment: 1 },
},
create: {
quelle,
letzterLauf: zeitpunkt,
letzterFehler: meldung.slice(0, 500),
fehlversuche: 1,
},
select: { fehlversuche: true },
})
return zeile.fehlversuche
}