diff --git a/src/app/api/spiegel/route.ts b/src/app/api/spiegel/route.ts new file mode 100644 index 0000000..cbfeea9 --- /dev/null +++ b/src/app/api/spiegel/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from 'next/server' +import { auditSchreiben } from '@/server/audit' +import { sendungAufnehmen, sendungPruefen } from '@/server/integration/spiegel' +import { jetzt } from '@/server/zeitgeber' + +export const dynamic = 'force-dynamic' + +/** + * Der Eingang des Spiegels — nur in der Instanz `daheim`. + * + * Hier meldet sich kein Mensch an: Die Gegenstelle weist sich über eine + * Signatur aus, die sie mit dem gemeinsamen Geheimnis über den Rumpf bildet. + * + * Jede Zurückweisung sieht gleich aus. Ob das Geheimnis falsch war, die Uhr + * verstellt oder der Inhalt Unsinn — von außen ist das nicht zu unterscheiden, + * und genau so soll es sein. + */ + +export async function POST(anfrage: Request) { + // Der Rumpf muss als Text gelesen werden: Die Signatur gilt für genau diese + // Zeichen, nicht für das, was ein JSON-Parser daraus und wieder zurück macht. + const koerper = await anfrage.text() + const signatur = anfrage.headers.get('X-Planer-Signatur') + + const geprueft = sendungPruefen(koerper, signatur, jetzt()) + if (!geprueft.ok) { + return NextResponse.json({ fehler: 'Abgelehnt.' }, { status: 401 }) + } + + const bericht = await sendungAufnehmen(geprueft.sendung, { + id: null, + email: 'spiegel@intern', + }) + + await auditSchreiben({ + akteur: { id: null, email: 'spiegel@intern' }, + aktion: 'spiegel.empfangen', + objektTyp: 'Spiegelbelegung', + objektId: null, + // Nur Zahlen: Was gespiegelt wurde, steht in den Datensätzen selbst. + details: bericht, + }) + + return NextResponse.json({ ok: true, ...bericht }) +} diff --git a/src/server/integration/aufnahme.ts b/src/server/integration/aufnahme.ts new file mode 100644 index 0000000..f94dc27 --- /dev/null +++ b/src/server/integration/aufnahme.ts @@ -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 { + 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 { + 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 { + 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 { + 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 +} diff --git a/src/server/integration/spiegel.ts b/src/server/integration/spiegel.ts new file mode 100644 index 0000000..fb5819b --- /dev/null +++ b/src/server/integration/spiegel.ts @@ -0,0 +1,213 @@ +import 'server-only' +import { createHmac, timingSafeEqual } from 'node:crypto' +import { + sendungFrisch, + spiegelSendung, + type Fund, + type SpiegelSendung, +} from '@/lib/integration' +import { db } from '@/server/db' +import { umgebung } from '@/server/umgebung' +import { jetzt } from '@/server/zeitgeber' +import { + fundeAufnehmen, + laufGescheitert, + laufGeglueckt, + type Anstoss, +} from '@/server/integration/aufnahme' + +/** + * Der Spiegel: `firma` sendet, `daheim` empfängt. + * + * Zwei Dinge gehen hinüber. **Belegungen** sagen nur, wann Zeit weg ist — + * ohne Titel, ohne Aufgabe, ohne Kunde. So kollidiert die private Planung + * nicht mit der dienstlichen, ohne dass Firmeninhalte das Haus verlassen. + * **Aufgaben** gehen nur mit, wenn sie ausdrücklich dafür markiert sind; das + * ist eine bewusste Einzelentscheidung, kein Schalter für alles. + * + * Die Richtung ist fest verdrahtet: Was in der Firma passiert, darf daheim + * sichtbar sein, umgekehrt nicht. Das ist keine Technik, sondern eine + * Hausregel — und deshalb steckt sie in den Instanzfähigkeiten, nicht in + * einer Einstellung, die sich verstellen ließe. + */ + +/** Wie weit voraus gespiegelt wird. Weiter zurück interessiert niemanden. */ +const FENSTER_TAGE_VORAUS = 30 + +export function signieren(koerper: string, geheimnis: string): string { + return createHmac('sha256', geheimnis).update(koerper).digest('hex') +} + +/** + * Prüft die Signatur in gleichbleibender Zeit. + * + * Ein einfacher Vergleich verrät über die Laufzeit, wie viele Zeichen schon + * stimmen — damit ließe sich die Signatur Stück für Stück erraten. + */ +export function signaturStimmt( + koerper: string, + signatur: string, + geheimnis: string, +): boolean { + const erwartet = Buffer.from(signieren(koerper, geheimnis), 'utf8') + const bekommen = Buffer.from(signatur, 'utf8') + if (erwartet.length !== bekommen.length) return false + return timingSafeEqual(erwartet, bekommen) +} + +// --- Senden (firma) -------------------------------------------------------- + +/** Stellt zusammen, was hinübergeht. */ +export async function sendungBauen(zeitpunkt: Date): Promise { + const bis = new Date(zeitpunkt.getTime() + FENSTER_TAGE_VORAUS * 86_400_000) + + const [zeitbloecke, aufgaben] = await Promise.all([ + db.zeitblock.findMany({ + where: { beginn: { gte: zeitpunkt, lte: bis } }, + orderBy: { beginn: 'asc' }, + take: 500, + // Kein Titel, keine Aufgabe: Nur wann die Zeit weg ist. + select: { id: true, beginn: true, ende: true }, + }), + db.aufgabe.findMany({ + where: { spiegeln: true }, + orderBy: { erstelltAm: 'asc' }, + take: 500, + select: { id: true, titel: true, faelligAm: true, status: true }, + }), + ]) + + return { + gesendetAm: zeitpunkt.toISOString(), + belegungen: zeitbloecke.map((z) => ({ + ref: z.id, + beginn: z.beginn.toISOString(), + ende: z.ende.toISOString(), + })), + aufgaben: aufgaben.map((a) => ({ + ref: a.id, + titel: a.titel, + faelligAm: a.faelligAm ? a.faelligAm.toISOString() : null, + erledigt: a.status === 'ERLEDIGT' || a.status === 'VERWORFEN', + })), + } +} + +/** Schickt eine Runde an die andere Instanz. */ +export async function spiegelSenden(): Promise<{ belegungen: number; aufgaben: number }> { + const einst = umgebung() + try { + const sendung = await sendungBauen(jetzt()) + const koerper = JSON.stringify(sendung) + + const gelesen = await fetch(`${einst.SPIEGEL_ZIEL.replace(/\/+$/, '')}/api/spiegel`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Planer-Signatur': signieren(koerper, einst.SPIEGEL_GEHEIMNIS), + }, + body: koerper, + signal: AbortSignal.timeout(20_000), + }) + + if (!gelesen.ok) { + throw new Error(`Der Spiegel antwortete mit HTTP ${gelesen.status}.`) + } + + await laufGeglueckt('SPIEGEL') + return { belegungen: sendung.belegungen.length, aufgaben: sendung.aufgaben.length } + } catch (fehler) { + await laufGescheitert('SPIEGEL', fehler) + throw fehler + } +} + +// --- Empfangen (daheim) ---------------------------------------------------- + +export type Empfangsbericht = { + belegungen: number + entfernt: number + aufgaben: number +} + +/** + * Nimmt eine geprüfte Sendung auf. + * + * Belegungen werden abgeglichen, nicht angehäuft: Was der Sender nicht mehr + * schickt, ist drüben ausgeplant worden und verschwindet auch hier. Sonst + * stünde daheim für immer eine Sperre, die es längst nicht mehr gibt. + */ +export async function sendungAufnehmen( + sendung: SpiegelSendung, + anstoss: Anstoss, +): Promise { + const gesehen = new Set(sendung.belegungen.map((b) => b.ref)) + + for (const belegung of sendung.belegungen) { + const werte = { + beginn: new Date(belegung.beginn), + ende: new Date(belegung.ende), + empfangenAm: jetzt(), + } + await db.spiegelbelegung.upsert({ + where: { quelleRef: belegung.ref }, + update: werte, + create: { quelleRef: belegung.ref, ...werte }, + }) + } + + const veraltet = await db.spiegelbelegung.findMany({ + where: { quelleRef: { notIn: [...gesehen] } }, + select: { id: true }, + }) + if (veraltet.length > 0) { + await db.spiegelbelegung.deleteMany({ + where: { id: { in: veraltet.map((v) => v.id) } }, + }) + } + + const funde: Fund[] = sendung.aufgaben.map((a) => ({ + ref: a.ref, + titel: a.titel, + faelligAm: a.faelligAm ? new Date(a.faelligAm) : null, + erledigt: a.erledigt, + })) + const bericht = await fundeAufnehmen('SPIEGEL', funde, anstoss) + + await laufGeglueckt('SPIEGEL') + + return { + belegungen: sendung.belegungen.length, + entfernt: veraltet.length, + aufgaben: bericht.angelegt + bericht.aktualisiert + bericht.geschlossen, + } +} + +/** + * Prüft Signatur und Frische und gibt die Sendung frei. + * + * Gibt bei jedem Fehlschlag dieselbe knappe Auskunft: Wer von außen probiert, + * soll nicht erfahren, ob das Geheimnis stimmte und nur die Uhr falsch ging. + */ +export function sendungPruefen( + koerper: string, + signatur: string | null, + zeitpunkt: Date, +): { ok: true; sendung: SpiegelSendung } | { ok: false } { + const einst = umgebung() + if (!einst.spiegelEmpfangenAktiv || !signatur) return { ok: false } + if (!signaturStimmt(koerper, signatur, einst.SPIEGEL_GEHEIMNIS)) return { ok: false } + + let inhalt: unknown + try { + inhalt = JSON.parse(koerper) + } catch { + return { ok: false } + } + + const gelesen = spiegelSendung.safeParse(inhalt) + if (!gelesen.success) return { ok: false } + if (!sendungFrisch(new Date(gelesen.data.gesendetAm), zeitpunkt)) return { ok: false } + + return { ok: true, sendung: gelesen.data } +} diff --git a/src/server/integration/telegram.ts b/src/server/integration/telegram.ts new file mode 100644 index 0000000..68ea363 --- /dev/null +++ b/src/server/integration/telegram.ts @@ -0,0 +1,392 @@ +import 'server-only' +import { randomBytes } from 'node:crypto' +import { z } from 'zod' +import { alsTitel, type Fund } from '@/lib/integration' +import type { Meldung } from '@/lib/benachrichtigung' +import { auditSchreiben } from '@/server/audit' +import { db } from '@/server/db' +import { umgebung } from '@/server/umgebung' +import { jetzt } from '@/server/zeitgeber' +import { + fundeAufnehmen, + laufGescheitert, + laufGeglueckt, + merkerLesen, + type Anstoss, +} from '@/server/integration/aufnahme' + +/** + * Telegram – nur in der Instanz „daheim". + * + * Drei Dinge in einem Kanal: erfassen (Nachricht wird Aufgabe), melden + * (dritter Weg neben Push und Mail) und abhaken (Knopf unter der Meldung). + * + * Geholt wird per `getUpdates`, nicht per Webhook. Ein Webhook verlangt eine + * von außen erreichbare Adresse mit gültigem Zertifikat; der Planer soll auch + * hinter einem Anschluss ohne feste Adresse laufen. + * + * **Wer nicht im Adressbuch steht, wird verworfen.** Ein Bot-Token lässt sich + * abgreifen, und wer die Kennung des Bots kennt, kann ihm schreiben. Die + * erlaubten Chats stehen in der Datenbank; alles andere wird stillschweigend + * weggeworfen — eine Antwort wäre schon die Auskunft, dass es den Bot gibt. + */ + +const HOECHSTENS = 50 + +const nachricht = z.object({ + message_id: z.number().int(), + date: z.number().int(), + chat: z.object({ id: z.number().int() }), + text: z.string().optional(), +}) + +const rueckfrage = z.object({ + id: z.string(), + data: z.string().optional(), + message: z + .object({ message_id: z.number().int(), chat: z.object({ id: z.number().int() }) }) + .optional(), +}) + +const aktualisierung = z.object({ + update_id: z.number().int(), + message: nachricht.optional(), + callback_query: rueckfrage.optional(), +}) + +const antwort = z.object({ + ok: z.boolean(), + result: z.array(z.unknown()).optional(), + description: z.string().optional(), +}) + +async function rufen( + methode: string, + koerper: Record, +): Promise { + const einst = umgebung() + const gelesen = await fetch( + `https://api.telegram.org/bot${einst.TELEGRAM_TOKEN}/${methode}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(koerper), + signal: AbortSignal.timeout(70_000), + }, + ) + + if (!gelesen.ok) { + // Der Körper enthält im Fehlerfall den Token nicht, wohl aber die + // Nachricht – deshalb nur der Status. + throw new Error(`Telegram antwortete mit HTTP ${gelesen.status}.`) + } + + const inhalt: unknown = await gelesen.json() + const geprueft = antwort.safeParse(inhalt) + if (!geprueft.success || !geprueft.data.ok) { + throw new Error('Telegram lehnte die Anfrage ab.') + } + return geprueft.data.result +} + +// --- Senden ---------------------------------------------------------------- + +export type Knopf = { text: string; daten: string } + +/** Schickt eine Nachricht in einen Chat. Gibt zurück, ob es geklappt hat. */ +export async function telegramSenden( + chatId: bigint, + text: string, + knoepfe: readonly Knopf[] = [], +): Promise { + try { + await rufen('sendMessage', { + chat_id: Number(chatId), + text, + ...(knoepfe.length > 0 + ? { + reply_markup: { + inline_keyboard: [ + knoepfe.map((k) => ({ text: k.text, callback_data: k.daten })), + ], + }, + } + : {}), + }) + return true + } catch (fehler) { + console.error( + 'Telegram-Versand fehlgeschlagen:', + fehler instanceof Error ? fehler.message : 'unbekannter Fehler', + ) + return false + } +} + +/** + * Schickt eine Meldung an alle Chats eines Benutzers, die Meldungen wollen. + * + * Hängt eine Aufgabe daran, kommt ein Knopf mit: abhaken, ohne die Anwendung + * zu öffnen. Genau dafür lohnt sich der Umweg über Telegram. + */ +export async function telegramMeldung( + benutzerId: string, + meldung: Meldung, +): Promise { + const chats = await db.telegramChat.findMany({ + where: { benutzerId, meldungen: true }, + select: { id: true, chatId: true }, + }) + + const einst = umgebung() + const text = `${meldung.titel}\n\n${meldung.text}\n\n${einst.APP_URL}${meldung.pfad}` + const knoepfe: Knopf[] = meldung.objektId + ? [{ text: '✓ Erledigt', daten: `erledigt:${meldung.objektId}` }] + : [] + + let zugestellt = 0 + for (const chat of chats) { + if (await telegramSenden(chat.chatId, text, knoepfe)) { + zugestellt++ + await db.telegramChat.update({ + where: { id: chat.id }, + data: { zuletztBenutzt: jetzt() }, + }) + } + } + return zugestellt +} + +// --- Kopplung -------------------------------------------------------------- + +/** + * Wie lange ein Kopplungscode gilt. + * + * Kurz genug, dass ein abgeschriebener Code nichts mehr nützt; lang genug, + * um in Ruhe das Handy zu suchen. + */ +export const KOPPLUNG_MINUTEN = 10 + +/** Ziffern und Großbuchstaben ohne die, die sich verwechseln lassen. */ +const ZEICHEN = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + +/** + * Der Code, allein oder hinter `/start`. + * + * Telegram schickt beim ersten Öffnen eines Bots `/start` mit; wer den Code + * abtippt, schickt ihn nackt. Beides muss gehen. + */ +const KOPPLUNGSBEFEHL = /^(?:\/start\s+)?([A-Za-z0-9]{8})$/ + +export function kopplungscodeBauen(): string { + const bytes = randomBytes(8) + return [...bytes] + .map((b) => ZEICHEN[b % ZEICHEN.length]) + .join('') + .slice(0, 8) +} + +/** + * Löst einen Code ein und trägt den Chat ein. + * + * Der Code gilt genau einmal: Er verschwindet mit dem ersten Einlösen, auch + * wenn dabei etwas schiefgeht. + */ +async function kopplungEinloesen( + code: string, + chatId: number, +): Promise<'gekoppelt' | 'schon-da' | 'unbekannt'> { + const kopplung = await db.telegramKopplung.findUnique({ + where: { code }, + select: { id: true, benutzerId: true, gueltigBis: true }, + }) + if (!kopplung) return 'unbekannt' + + await db.telegramKopplung.delete({ where: { id: kopplung.id } }) + if (kopplung.gueltigBis.getTime() < jetzt().getTime()) return 'unbekannt' + + const vorhanden = await db.telegramChat.findUnique({ + where: { chatId: BigInt(chatId) }, + select: { id: true }, + }) + if (vorhanden) return 'schon-da' + + const chat = await db.telegramChat.create({ + data: { chatId: BigInt(chatId), benutzerId: kopplung.benutzerId }, + select: { id: true }, + }) + + await auditSchreiben({ + akteur: { id: kopplung.benutzerId, email: 'telegram@intern' }, + aktion: 'telegram.gekoppelt', + objektTyp: 'TelegramChat', + objektId: chat.id, + // Die Chatkennung gehört zu einer Person und bleibt draußen. + }) + return 'gekoppelt' +} + +/** Entfernt abgelaufene Codes. */ +export async function alteKopplungenEntfernen(zeitpunkt: Date): Promise { + const weg = await db.telegramKopplung.deleteMany({ + where: { gueltigBis: { lt: zeitpunkt } }, + }) + return weg.count +} + +// --- Holen ----------------------------------------------------------------- + +/** Ein bekannter Chat, oder null. Unbekannte werden nicht beantwortet. */ +async function chatPruefen(chatId: number) { + return db.telegramChat.findUnique({ + where: { chatId: BigInt(chatId) }, + select: { id: true, benutzerId: true }, + }) +} + +/** + * Hakt eine Aufgabe über den Knopf unter einer Meldung ab. + * + * Nur abhaken, nichts anderes: Ein Knopf, der löscht oder verschiebt, wäre + * aus einem Chat heraus zu leicht versehentlich getroffen. + */ +async function erledigtMelden(aufgabeId: string, anstoss: Anstoss): Promise { + const aufgabe = await db.aufgabe.findUnique({ + where: { id: aufgabeId }, + select: { id: true, titel: true, status: true }, + }) + if (!aufgabe) return 'Diese Aufgabe gibt es nicht mehr.' + if (aufgabe.status === 'ERLEDIGT') return 'War schon erledigt.' + + await db.aufgabe.update({ + where: { id: aufgabe.id }, + data: { status: 'ERLEDIGT', erledigtAm: jetzt() }, + }) + await auditSchreiben({ + akteur: anstoss, + aktion: 'aufgabe.erledigt', + objektTyp: 'Aufgabe', + objektId: aufgabe.id, + details: { ueber: 'telegram' }, + }) + return 'Erledigt.' +} + +/** + * Holt alles Neue ab und arbeitet es ab. + * + * Der Merker ist die Kennung der zuletzt geholten Aktualisierung. Telegram + * löscht alles darunter, sobald wir mit dem nächsten Offset fragen — das ist + * zugleich die Bestätigung, dass wir es haben. + */ +export async function telegramAbholen(anstoss: Anstoss): Promise<{ + aufgaben: number + abgehakt: number + gekoppelt: number + verworfen: number +}> { + try { + const merker = await merkerLesen('TELEGRAM') + const offset = merker ? Number(merker) : undefined + + const roh = await rufen('getUpdates', { + ...(offset === undefined || Number.isNaN(offset) ? {} : { offset }), + limit: HOECHSTENS, + // Kein Long-Polling: Der Job läuft ohnehin im Takt und soll nicht + // minutenlang eine Verbindung offen halten. + timeout: 0, + allowed_updates: ['message', 'callback_query'], + }) + + const eintraege = z.array(aktualisierung).safeParse(roh) + if (!eintraege.success) throw new Error('Telegram antwortete in unbekannter Form.') + + const funde: Fund[] = [] + let abgehakt = 0 + let gekoppelt = 0 + let verworfen = 0 + let hoechste = offset ? offset - 1 : 0 + + for (const eintrag of eintraege.data) { + hoechste = Math.max(hoechste, eintrag.update_id) + + if (eintrag.callback_query) { + const chatId = eintrag.callback_query.message?.chat.id + const bekannt = chatId === undefined ? null : await chatPruefen(chatId) + if (!bekannt) { + verworfen++ + continue + } + + const daten = eintrag.callback_query.data ?? '' + const [was, id] = daten.split(':') + const meldung = + was === 'erledigt' && id + ? await erledigtMelden(id, { + id: bekannt.benutzerId, + email: anstoss.email, + }) + : 'Unbekannter Knopf.' + abgehakt += meldung === 'Erledigt.' ? 1 : 0 + + await rufen('answerCallbackQuery', { + callback_query_id: eintrag.callback_query.id, + text: meldung, + }).catch(() => undefined) + continue + } + + const nachricht = eintrag.message + if (!nachricht?.text) continue + + const bekannt = await chatPruefen(nachricht.chat.id) + if (!bekannt) { + // Die einzige Ausnahme: ein gültiger Kopplungscode. Er ist der + // Beweis, dass jemand mit Zugang zur Anwendung diesen Chat will. + const code = KOPPLUNGSBEFEHL.exec(nachricht.text.trim())?.[1]?.toUpperCase() + if (code) { + const ergebnis = await kopplungEinloesen(code, nachricht.chat.id) + if (ergebnis !== 'unbekannt') { + gekoppelt++ + await telegramSenden( + BigInt(nachricht.chat.id), + ergebnis === 'gekoppelt' + ? 'Verbunden. Schick mir eine Nachricht, und daraus wird eine Aufgabe.' + : 'Dieser Chat war schon verbunden.', + ) + continue + } + } + // Sonst kein Wort zurück. Wer nicht eingetragen ist, soll nicht + // erfahren, dass hier etwas läuft. + verworfen++ + continue + } + + const { titel, rest } = alsTitel(nachricht.text) + if (titel.length === 0) continue + + funde.push({ + ref: `${nachricht.chat.id}:${nachricht.message_id}`, + titel, + notiz: rest, + }) + + await db.telegramChat.update({ + where: { id: bekannt.id }, + data: { zuletztBenutzt: jetzt() }, + }) + } + + const bericht = await fundeAufnehmen('TELEGRAM', funde, anstoss) + + // Erst bestätigen, wenn alles verarbeitet ist: Ein Offset, der zu früh + // hochgezählt wird, verliert Nachrichten unwiederbringlich. + await laufGeglueckt('TELEGRAM', String(hoechste + 1)) + + return { aufgaben: bericht.angelegt, abgehakt, gekoppelt, verworfen } + } catch (fehler) { + await laufGescheitert('TELEGRAM', fehler) + throw fehler + } +} diff --git a/src/server/integration/zammad.ts b/src/server/integration/zammad.ts new file mode 100644 index 0000000..5a34395 --- /dev/null +++ b/src/server/integration/zammad.ts @@ -0,0 +1,143 @@ +import 'server-only' +import { z } from 'zod' +import { alsTitel, type Fund } from '@/lib/integration' +import { umgebung } from '@/server/umgebung' +import { + fundeAufnehmen, + laufGescheitert, + laufGeglueckt, + type Anstoss, + type Aufnahmebericht, +} from '@/server/integration/aufnahme' + +/** + * Zammad – nur in der Instanz „firma". + * + * Gelesen wird über die Suche mit einem Ausdruck aus der Konfiguration; was + * darauf passt, wird zur Aufgabe. Der Token gehört einem Benutzer und sieht + * genau das, was dieser Benutzer sehen darf — der Planer umgeht keine Rechte. + * + * Geschrieben wird nach Zammad **nicht**. Ein Ticket schließt man im + * Ticketsystem, sonst weiß die Kollegin nicht, warum es plötzlich zu ist. + */ + +/** Wie viele Tickets höchstens je Lauf. Mehr wäre ohnehin keine Planung. */ +const HOECHSTENS = 100 + +/** + * Zammads Zuständen ist nicht anzusehen, ob sie „fertig" bedeuten. Der + * `state_type` sagt es; die Suche liefert ihn nicht immer mit, deshalb + * zusätzlich die üblichen Namen. + */ +const ERLEDIGT_ZUSTAENDE = ['closed', 'merged', 'removed'] + +const ticket = z.object({ + id: z.number().int(), + number: z.string(), + title: z.string(), + state: z.string().optional(), + /** 1 low, 2 normal, 3 high – Zammads Voreinstellung. */ + priority: z.string().optional(), + pending_time: z.string().nullable().optional(), +}) + +type Ticket = z.infer + +const antwort = z.object({ + assets: z.object({ Ticket: z.record(z.string(), z.unknown()).optional() }).optional(), + tickets: z.array(z.number().int()).optional(), +}) + +/** + * Übersetzt Zammads Priorität in unsere Skala. + * + * Zammad: 1 niedrig, 2 normal, 3 hoch. Hier: 1 dringend … 5 irgendwann. + * Die Zahlen laufen also gegenläufig, was beim Lesen gern übersehen wird. + */ +function prioritaetVon(zammad: string | undefined): number | null { + if (!zammad) return null + if (zammad.startsWith('3')) return 2 + if (zammad.startsWith('1')) return 4 + return 3 +} + +function alsFund(t: Ticket, basis: string): Fund { + const { titel, rest } = alsTitel(`#${t.number} ${t.title}`) + const zustand = (t.state ?? '').toLowerCase() + + return { + ref: String(t.id), + titel, + notiz: rest, + url: `${basis}/#ticket/zoom/${t.id}`, + faelligAm: t.pending_time ? new Date(t.pending_time) : null, + prioritaet: prioritaetVon(t.priority), + erledigt: ERLEDIGT_ZUSTAENDE.some((z) => zustand.includes(z)), + } +} + +/** Holt die Tickets, auf die der Suchausdruck passt. */ +export async function ticketsHolen(): Promise { + const einst = umgebung() + const basis = einst.ZAMMAD_URL.replace(/\/+$/, '') + + const adresse = new URL(`${basis}/api/v1/tickets/search`) + adresse.searchParams.set('query', einst.ZAMMAD_ABFRAGE) + adresse.searchParams.set('limit', String(HOECHSTENS)) + // Ohne "full" liefert die Suche nur Kennungen und wir müssten jedes Ticket + // einzeln nachladen. + adresse.searchParams.set('expand', 'true') + + const gelesen = await fetch(adresse, { + headers: { + Authorization: `Token token=${einst.ZAMMAD_TOKEN}`, + Accept: 'application/json', + }, + // Ein hängendes Ticketsystem darf den Job nicht festhalten. + signal: AbortSignal.timeout(20_000), + }) + + if (!gelesen.ok) { + // Nie den Antwortkörper: Zammad schickt im Fehlerfall gern den ganzen + // Datensatz mit. + throw new Error(`Zammad antwortete mit HTTP ${gelesen.status}.`) + } + + const inhalt: unknown = await gelesen.json() + + // Mit expand=true ist die Antwort eine Liste, ohne ein Objekt mit assets. + if (Array.isArray(inhalt)) { + return inhalt + .map((eintrag) => ticket.safeParse(eintrag)) + .filter((g) => g.success) + .map((g) => alsFund(g.data, basis)) + } + + const huelle = antwort.safeParse(inhalt) + if (!huelle.success) throw new Error('Zammad antwortete in unbekannter Form.') + + const werte = Object.values(huelle.data.assets?.Ticket ?? {}) + return werte + .map((eintrag) => ticket.safeParse(eintrag)) + .filter((g) => g.success) + .map((g) => alsFund(g.data, basis)) +} + +/** + * Ein Durchlauf: holen, aufnehmen, Zustand fortschreiben. + * + * Scheitert das Holen, bleibt der bisherige Stand stehen. Eine leere Antwort + * darf niemals als „alles erledigt" durchgehen — deshalb schließt diese + * Integration nur, was Zammad ausdrücklich als geschlossen liefert. + */ +export async function zammadAbgleichen(anstoss: Anstoss): Promise { + try { + const funde = await ticketsHolen() + const bericht = await fundeAufnehmen('ZAMMAD', funde, anstoss) + await laufGeglueckt('ZAMMAD') + return bericht + } catch (fehler) { + await laufGescheitert('ZAMMAD', fehler) + throw fehler + } +}