From 5bf42f461060b4508351ee1b3e50d574d14b7d6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Prie=C3=9Fnitz?= Date: Wed, 3 Dec 2025 13:24:25 +0100 Subject: [PATCH] [deploy] add ticket sla, pipeline, automations, kb --- prisma/schema.prisma | 30 +++- readme.md | 132 +++++++++-------- src/commands/tickets/status.ts | 9 +- src/config/context.ts | 6 +- src/database/schema.prisma | 30 +++- src/events/messageCreate.ts | 3 + src/index.ts | 1 + src/services/knowledgeBaseService.ts | 34 +++++ src/services/ticketAutomationService.ts | 103 +++++++++++++ src/services/ticketService.ts | 144 +++++++++++++------ src/utils/types.ts | 9 +- src/web/routes/api.ts | 183 ++++++++++++++++++++++++ 12 files changed, 561 insertions(+), 123 deletions(-) create mode 100644 src/services/knowledgeBaseService.ts create mode 100644 src/services/ticketAutomationService.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9382fc9..a2b295d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -40,13 +40,41 @@ model Ticket { guildId String topic String? priority String @default("normal") - status String + status String @default("neu") claimedBy String? transcript String? + firstClaimAt DateTime? + firstResponseAt DateTime? + kbSuggestionSentAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } +model TicketAutomationRule { + id String @id @default(cuid()) + guildId String + name String + condition Json + action Json + active Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([guildId, active]) +} + +model KnowledgeBaseArticle { + id String @id @default(cuid()) + guildId String + title String + keywords String[] + content String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([guildId]) +} + model Level { id String @id @default(cuid()) userId String diff --git a/readme.md b/readme.md index 6eb6c70..5d37593 100644 --- a/readme.md +++ b/readme.md @@ -1,77 +1,73 @@ # Papo Discord Bot -## 1. Projektueberblick -Papo ist ein Discord-Bot (discord.js 14, TypeScript) mit Web-Dashboard. Implementiert sind: -- Ticketsystem: Ticket-Channel pro Nutzer, Slash-Commands (/ticket, /claim, /close, /ticketpriority, /ticketstatus, /transcript, /ticketpanel), Panel-Erstellung via Bot und Dashboard; Transcripts als Textdatei unter `./transcripts`. Inklusive Support-Login-Panel (Rolle vergeben/entfernen, On-Duty-Logging). -- Automod: Link-Filter mit Whitelist, Spam- und Caps-Erkennung, Bad-Word-Filter mit Custom-Listen, Zeitueberschreitung bei Spam, Logging der Aktionen. -- Musik-Modul: Wiedergabe via play-dl, Queue mit Loop/Skip/Stop/Pause/Resume, per Slash-Commands; Modul pro Guild abschaltbar. -- Welcome-Modul: Begruessungs-Embeds mit konfigurierbarer Farbe, Titel, Beschreibung, Footer, Thumbnails/Bilder (Upload oder URL), Aktivierung pro Guild, Vorschau im Dashboard; Fallback auf Text-Begruesung, wenn nur `welcomeChannelId` gesetzt ist. -- Logging-Modul: Join/Leave, Message Edit/Delete, Automod/Ticket/Musik-Events, konfigurierbarer Log-Channel und Kategorien. -- Leveling: XP/Level pro Nachricht, Anzeige via /rank, Toggle ueber Settings. -- Dynamische Voice Channels: Lobby-gestuetzte Erstellung privater Voice-Channels mit automatischem Move und Berechtigungen. -- Birthday-Modul: /birthday zum Setzen des Geburtsdatums, konfigurierbare Channel-/Template-Einstellungen, automatische Glueckwuensche. -- Reaction Roles: Dashboard-Konfiguration, Bot reagiert auf Reactions und vergibt Rollen. -- Termine/Events: Einmalige/recurring Events (taeglich/woechentlich/monatlich), Reminder, An-/Abmelde-Buttons, pro Event eigener Channel/Ping-Rolle. -- Modul-Management im Dashboard: Toggles fuer Tickets, Automod, Welcome, Musik, Leveling, Dynamische Voice, Statuspage, Birthday, Reaction Roles, Events. -- Dashboard: Sidebar-Navigation (Uebersicht, Tickets, Automod, Welcome, Dynamic Voice, Statuspage, Birthday, Reaction Roles, Events, Module, Einstellungen), OAuth2-Login (Scopes identify, guilds), Guild-Auswahl und modulabhaengige Sichtbarkeit. +Discord-Bot (discord.js 14, TypeScript) mit Web-Dashboard, Prisma/PostgreSQL und Docker-Support. -## 2. Installation / Setup -Voraussetzungen: -- Node.js 18+ (CommonJS, ts-node fuer Dev) -- Postgres-Datenbank zugaenglich per Connection-String -- Discord-Anwendung/Bot mit Berechtigungen: Slash Commands, Nachrichten senden/lesen, Embed Links, Dateien senden, Channels verwalten (Tickets), Nachrichten loeschen/timeouts (Automod), Voice Connect/Speak (Musik) -- OAuth Redirect: `http://localhost:PORT/auth/callback` oder eigener `DASHBOARD_BASE_URL` +## Was drin ist +- Ticketsystem: Slash-Commands (/ticket, /claim, /close, /ticketpriority, /ticketstatus, /transcript, /ticketpanel), Panels, Transcripts unter `./transcripts`, Support-Login-Panel mit Rollen-Vergabe/On-Duty-Logging. +- Automod: Link-Filter (Whitelist), Spam/Caps-Erkennung, Bad-Word-Listen (Custom), Timeouts, Logging. +- Musik: play/skip/stop/pause/resume/loop, Queue, aktivierbar/deaktivierbar pro Guild. +- Welcome: konfigurierbare Embeds (Channel, Farbe, Texte, Bilder/Uploads), Preview im Dashboard, Text-Fallback. +- Logging: Join/Leave, Message Edit/Delete, Automod/Ticket/Musik-Events mit konfigurierbarem Log-Channel/Kategorien. +- Leveling: XP/Level pro Nachricht, /rank, toggelbar. +- Dynamische Voice: Lobby erzeugt private Voice-Channels mit Template/Userlimit. +- Birthday: /birthday + geplante Glueckwuensche mit Template/Channel. +- Reaction Roles: Verwaltung im Dashboard, Sync/Loeschen/Erstellen. +- Events: Einmalig/recurring, Reminder, Signups, Buttons. +- Statuspage-Modul vorhanden (Config/API), plus Modul-Toggles im Dashboard. +- Dashboard: OAuth2 (Scopes identify, guilds), zeigt nur Guilds, die der Nutzer besitzt oder mit Manage Guild/Admin-Rechten verwalten darf **und** in denen der Bot ist. Modulabhaengige Navigation. +- Rich Presence: rotiert mit `/help`, Dashboard-URL und Guild-Zaehler. -Schritte: -1) Repository klonen und ins Projekt wechseln. -2) Abhaengigkeiten installieren: npm install. -3) .env aus .env.example kopieren und Variablen setzen (siehe Liste unten). -4) Prisma vorbereiten: npx prisma generate, danach npx prisma migrate dev --name init (legt Migrationen an; bei neuen Features ggf. weitere wie `add-events-module`). -5) Entwicklung starten: npm run dev (ts-node). -6) Produktion: npm run build, dann npm start (nutzt dist/index.js). Dashboard laeuft im selben Prozess auf PORT (default 3000). -7) Slash-Commands werden beim Start fuer alle IDs in DISCORD_GUILD_IDS (oder global) registriert. +## Tech-Stack +- Node.js 20 (Docker-Basis), TypeScript (CommonJS) +- discord.js 14, play-dl, @discordjs/voice +- Express + OAuth2-Login, Prisma ORM (PostgreSQL) +- Dockerfile + docker-compose (App + Postgres) -Environment-Variablen: -- DISCORD_TOKEN (Bot Token, Pflicht) -- DISCORD_CLIENT_ID (Client ID, Pflicht) -- DISCORD_CLIENT_SECRET (fuer Dashboard-OAuth erforderlich) -- DISCORD_GUILD_ID (optionale Einzel-Guild fuer Command-Registrierung) -- DISCORD_GUILD_IDS (kommagetrennte Liste fuer mehrere Guilds) -- DATABASE_URL (Postgres-URL, Pflicht) -- PORT (Webserver/Dashboard-Port, Standard 3000) -- SESSION_SECRET (Express Session Secret, Standard papo_dev_secret) -- DASHBOARD_BASE_URL (optional, Basis-URL fuer OAuth Redirects) -- WEB_BASE_PATH (optional, z.B. /ucp) -- OWNER_IDS (kommagetrennte Bot-Owner fuer Admin-UI) -- SUPPORT_ROLE_ID (optionale Support-Rolle fuer Tickets/Support-Login) +## Setup (lokal, Entwicklung) +1. Repo klonen, in das Verzeichnis wechseln. +2. `cp .env.example .env` und Variablen setzen (siehe unten). +3. Dependencies installieren: `npm ci` (oder `npm install`). +4. Prisma: `npx prisma generate --schema=src/database/schema.prisma` und `npx prisma migrate dev --name init`. +5. Start Dev: `npm run dev` (ts-node-dev). Dashboard und Bot laufen auf `PORT` (default 3000). +6. Slash-Commands werden beim Start fuer die IDs in `DISCORD_GUILD_IDS` (oder `DISCORD_GUILD_ID`) registriert. -## 3. Datenbank & Migrationen -- Prisma-Schema: `prisma/schema.prisma` (alias in `src/database/schema.prisma`). -- Migrationen per `npx prisma migrate dev --name `; danach `npx prisma generate`. -- Wesentliche Tabellen: - - GuildSettings: Module-Flags + Config (automod/welcome/logging/music/dynamicVoice/statuspage/birthday/reactionRoles/events/supportLogin). - - Ticket: id, ticketNumber, userId, channelId, guildId, topic, priority, status, claimedBy, transcript. - - TicketSupportSession: guildId, userId, roleId, startedAt/endedAt, durationSeconds. - - Event / EventSignup: Events mit Wiederholung/Reminder, Signups mit canceledAt. - - Birthday, ReactionRoleSet, Level. +## Setup mit Docker +- `.dockerignore` blendet lokale node_modules/.env aus. +- Dev-Stack: `docker-compose up --build` (nutzt `Dockerfile`, Postgres 15, env aus `.env`, `npm run dev` im Container). +- Eigenes Image: `docker build .` (Prisma-Generate laeuft im Build). -## 4. Module & Features (Details) -- Ticketsystem: Slash-Commands fuer Anlage/Claim/Close/Prioritaet/Status; Ticket-Panel per Command oder Dashboard (/api/tickets/panel). Dashboard zeigt Ticket-Liste, Detail-Modal, Close-Aktion; Transcripts via /api/tickets/:id/transcript; Tickets pro Guild deaktivierbar (Module-Toggle); Support-Rolle fuer Channel-Overwrites optional. -- Automod: Link-Filter mit Whitelist, Bad-Word-Filter (Default + Custom), Caps-Erkennung, Spam-Tracker (Threshold/Window/Timeout), Rollenausnahmen, Log-Channel; konfigurierbar im Dashboard (Switches, Whitelist-Felder, Log-Channel, Sensitivitaet). Aktionen werden optional geloggt. -- Musik: Queue-Handling mit play/skip/stop/pause/resume/loop, Status-Abfrage im Dashboard (activeGuilds) und Modul-Toggle; stopAll-Hook bei Deaktivierung; nutzt play-dl + @discordjs/voice. -- Welcome: Embeds im Dashboard konfigurierbar (Channel, Farbe, Titel, Beschreibung, Footer, Thumbnails/Bilder als URL oder Upload), Preview im UI; Bot sendet Embeds bei Join, faellt auf Text-Begruesung zurueck wenn nur welcomeChannelId gesetzt ist. -- Logging: Kategorien joinLeave/messageEdit/messageDelete/automodActions/ticketActions/musicEvents, Log-Channel im Dashboard konfigurierbar; nutzt LoggingService fuer Events. -- Leveling: XP pro Nachricht, /rank Anzeige, Toggle ueber Settings. Unique Constraint pro Guild/User. -- Dynamische Voice: Lobby-Channel erzeugt private Voice-Channels mit Name-Template, optionalem Userlimit; Benutzer wird automatisch verschoben, leere erzeugte Channels werden entfernt. -- Modulverwaltung: Dashboard-Seite Module mit Toggles fuer ticketsEnabled/automodEnabled/welcomeEnabled/musicEnabled/levelingEnabled; API /api/modules liefert Status pro Guild. -- Dashboard/Backend: OAuth2 Login (/auth/discord, /auth/callback), Session-Handling, Guild-Auswahl; API-Endpoints mit Auth-Guard (/api/me, /api/guilds, /api/overview, /api/tickets, /api/tickets/:id/messages, /api/tickets/:id/close, /api/tickets/panel, /api/settings GET/POST, /api/modules). Sidebar-Abschnitte Uebersicht/Tickets/Automod/Welcome/Module/Einstellungen mit modulabhaengiger Sichtbarkeit; Settings-Form fuer welcome/log/supportRole, Logging-Optionen, Automod- und Welcome-Formulare. -- Commands (implementiert): Admin (/ban, /tempban, /kick, /mute, /unmute, /timeout, /clear), Music (/play, /pause, /resume, /skip, /stop, /queue, /loop), Tickets (/ticket, /claim, /close, /ticketpriority, /ticketstatus, /transcript, /ticketpanel), Utility (/configure, /help, /ping, /rank, /serverinfo, /welcome). +## Environment-Variablen +- `DISCORD_TOKEN` (Pflicht, Bot Token) +- `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` (Pflicht fuer Dashboard-OAuth) +- `DISCORD_GUILD_ID` (optional Einzel-Guild fuer Commands) +- `DISCORD_GUILD_IDS` (kommagetrennt, mehrere Guilds) +- `DATABASE_URL` (Pflicht, Postgres) +- `PORT` (Webserver/Dashboard, default 3000) +- `SESSION_SECRET` (Express Session Secret, default `papo_dev_secret`) +- `DASHBOARD_BASE_URL` (Public Base URL, fuer OAuth Redirect) +- `WEB_BASE_PATH` (Default `/ucp`, ohne Slash am Ende) +- `OWNER_IDS` (kommagetrennte Owner fuer Admin-UI) +- `SUPPORT_ROLE_ID` (optional Ticket/Support-Login Rolle) -## 5. Roadmap / Planung -- SupportBot: Ansagen, Supportzeiten, Wartemusik (geplant) -- Embed-Builder (geplant) -- Team-Systeme: Abwesenheiten melden, Team-Dashboard (geplant) +## Datenbank / Prisma +- Schema: `src/database/schema.prisma` (zweites Schema in `prisma/schema.prisma` fuer Binary Targets). +- Migrationen: `npx prisma migrate dev --name `; danach `npx prisma generate --schema=src/database/schema.prisma`. +- Kern-Tabellen: GuildSettings (Module/Config), Ticket, TicketSupportSession, Event/EventSignup, Birthday, ReactionRoleSet, Level. -## 6. Credits & Lizenz -- Autoren/Maintainer: nicht angegeben. -- Lizenz: nicht angegeben (bitte vor Nutzung pruefen). +## Kommandos & Scripts +- `npm run dev` – Entwicklung (ts-node-dev) +- `npm run build` – TypeScript build +- `npm start` – Start aus `dist` +- Prisma-CLI: `npx prisma ...` (nutzt Schema aus `src/database/schema.prisma`) + +## Dashboard / API Kurzinfo +- Auth-Gate (`/api/*`), Login `/auth/discord`, Callback `/auth/callback`, Logout `/auth/logout`. +- `/api/guilds` filtert auf Guilds, die der eingeloggte User besitzt oder managen darf und in denen der Bot ist. +- Module/Settings ueber `/api/settings`, `/api/modules`, Tickets unter `/api/tickets*`, weitere Endpoints fuer Events, Reaction Roles, Birthday, Statuspage. + +## Deployment-Hinweise +- Produktion: `npm run build` + `npm start` oder Docker-Image nutzen. +- Transcripts werden unter `./transcripts` abgelegt (Volume mounten, falls Container). + +## Credits/Lizenz +- Autoren/Lizenz nicht hinterlegt. Bitte vor Nutzung pruefen. diff --git a/src/commands/tickets/status.ts b/src/commands/tickets/status.ts index a277a25..afa6db7 100644 --- a/src/commands/tickets/status.ts +++ b/src/commands/tickets/status.ts @@ -12,14 +12,15 @@ const command: SlashCommand = { .setName('status') .setDescription('Neuer Status') .addChoices( - { name: 'Offen', value: 'open' }, - { name: 'In Bearbeitung', value: 'in-progress' }, - { name: 'Geschlossen', value: 'closed' } + { name: 'Neu', value: 'neu' }, + { name: 'In Bearbeitung', value: 'in_bearbeitung' }, + { name: 'Warten auf User', value: 'warten_auf_user' }, + { name: 'Erledigt', value: 'erledigt' } ) .setRequired(true) ), async execute(interaction: ChatInputCommandInteraction) { - const status = interaction.options.getString('status', true) as 'open' | 'in-progress' | 'closed'; + const status = interaction.options.getString('status', true) as any; const ticket = await prisma.ticket.findFirst({ where: { channelId: interaction.channelId } }); if (!ticket) { await interaction.reply({ content: 'Kein Ticket in diesem Kanal.', ephemeral: true }); diff --git a/src/config/context.ts b/src/config/context.ts index 3c966c2..769e442 100644 --- a/src/config/context.ts +++ b/src/config/context.ts @@ -12,6 +12,8 @@ import { StatuspageService } from '../services/statuspageService'; import { BirthdayService } from '../services/birthdayService'; import { ReactionRoleService } from '../services/reactionRoleService'; import { EventService } from '../services/eventService'; +import { TicketAutomationService } from '../services/ticketAutomationService'; +import { KnowledgeBaseService } from '../services/knowledgeBaseService'; export const context = { client: null as Client | null, @@ -27,7 +29,9 @@ export const context = { statuspage: new StatuspageService(), birthdays: new BirthdayService(), reactionRoles: new ReactionRoleService(), - events: new EventService() + events: new EventService(), + ticketAutomation: new TicketAutomationService(), + knowledgeBase: new KnowledgeBaseService() }; context.modules.setHooks({ diff --git a/src/database/schema.prisma b/src/database/schema.prisma index 4bd2d64..d0216d0 100644 --- a/src/database/schema.prisma +++ b/src/database/schema.prisma @@ -39,13 +39,41 @@ model Ticket { guildId String topic String? priority String @default("normal") - status String + status String @default("neu") claimedBy String? transcript String? + firstClaimAt DateTime? + firstResponseAt DateTime? + kbSuggestionSentAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } +model TicketAutomationRule { + id String @id @default(cuid()) + guildId String + name String + condition Json + action Json + active Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([guildId, active]) +} + +model KnowledgeBaseArticle { + id String @id @default(cuid()) + guildId String + title String + keywords String[] + content String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([guildId]) +} + model Level { id String @id @default(cuid()) userId String diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index a5de3e5..d133104 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -10,6 +10,9 @@ const event: EventHandler = { if (message.guildId) context.admin.trackEvent('message', message.guildId); if (cfg?.automodEnabled === true) context.automod.checkMessage(message, cfg.automodConfig); if (cfg?.levelingEnabled === true) context.leveling.handleMessage(message); + // Ticket SLA + KB + await context.tickets.trackFirstResponse(message); + await context.tickets.suggestKnowledgeBase(message); } }; diff --git a/src/index.ts b/src/index.ts index 15859fb..d72470b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,7 @@ async function bootstrap() { context.admin.setClient(client); context.statuspage.setClient(client); context.tickets.setClient(client); + context.ticketAutomation.startLoop(); context.birthdays.setClient(client); context.reactionRoles.setClient(client); context.events.setClient(client); diff --git a/src/services/knowledgeBaseService.ts b/src/services/knowledgeBaseService.ts new file mode 100644 index 0000000..b224db6 --- /dev/null +++ b/src/services/knowledgeBaseService.ts @@ -0,0 +1,34 @@ +import { prisma } from '../database'; + +export class KnowledgeBaseService { + public async list(guildId: string) { + return prisma.knowledgeBaseArticle.findMany({ where: { guildId }, orderBy: { createdAt: 'asc' } }); + } + + public async save(article: { id?: string; guildId: string; title: string; keywords: string[]; content: string }) { + const data = { + guildId: article.guildId, + title: article.title, + keywords: article.keywords, + content: article.content + }; + if (article.id) { + return prisma.knowledgeBaseArticle.update({ where: { id: article.id }, data }); + } + return prisma.knowledgeBaseArticle.create({ data }); + } + + public async remove(guildId: string, id: string) { + const found = await prisma.knowledgeBaseArticle.findFirst({ where: { id, guildId } }); + if (!found) return false; + await prisma.knowledgeBaseArticle.delete({ where: { id } }); + return true; + } + + public async match(guildId: string, text: string) { + if (!text) return []; + const lowered = text.toLowerCase(); + const articles = await prisma.knowledgeBaseArticle.findMany({ where: { guildId }, take: 50 }); + return articles.filter((a) => (a.keywords || []).some((k) => lowered.includes(k.toLowerCase()))).slice(0, 3); + } +} diff --git a/src/services/ticketAutomationService.ts b/src/services/ticketAutomationService.ts new file mode 100644 index 0000000..3e78944 --- /dev/null +++ b/src/services/ticketAutomationService.ts @@ -0,0 +1,103 @@ +import { prisma } from '../database'; +import { context } from '../config/context'; +import { TextChannel } from 'discord.js'; + +type AutomationCondition = { + category?: string; + status?: string; + minHours?: number; +}; + +type AutomationAction = { + type: 'pingRole' | 'reminder' | 'flag'; + roleId?: string; + message?: string; + status?: string; +}; + +export class TicketAutomationService { + public async list(guildId: string) { + return prisma.ticketAutomationRule.findMany({ where: { guildId }, orderBy: { createdAt: 'asc' } }); + } + + public async save(rule: { + id?: string; + guildId: string; + name: string; + condition: AutomationCondition; + action: AutomationAction; + active?: boolean; + }) { + if (rule.id) { + return prisma.ticketAutomationRule.update({ + where: { id: rule.id }, + data: { + name: rule.name, + condition: rule.condition, + action: rule.action, + active: rule.active ?? true + } + }); + } + return prisma.ticketAutomationRule.create({ + data: { + guildId: rule.guildId, + name: rule.name, + condition: rule.condition, + action: rule.action, + active: rule.active ?? true + } + }); + } + + public async remove(guildId: string, id: string) { + const found = await prisma.ticketAutomationRule.findFirst({ where: { id, guildId } }); + if (!found) return false; + await prisma.ticketAutomationRule.delete({ where: { id } }); + return true; + } + + public async checkTicket(ticket: any, isScheduled = false) { + const rules = await prisma.ticketAutomationRule.findMany({ where: { guildId: ticket.guildId, active: true }, take: 50 }); + if (!rules.length) return; + const guild = context.client?.guilds.cache.get(ticket.guildId) ?? (await context.client?.guilds.fetch(ticket.guildId).catch(() => null)); + if (!guild) return; + const channel = ticket.channelId ? await guild.channels.fetch(ticket.channelId).catch(() => null) : null; + for (const rule of rules) { + const cond = (rule.condition as any) || {}; + const act = (rule.action as any) || {}; + const matchesCategory = + !cond.category || (ticket.topic || '').toLowerCase().includes(String(cond.category).toLowerCase()); + const matchesStatus = !cond.status || ticket.status === cond.status; + const matchesAge = + !cond.minHours || + (ticket.createdAt && + Date.now() - new Date(ticket.createdAt).getTime() >= Number(cond.minHours) * 3600 * 1000); + if (!matchesCategory || !matchesStatus || !matchesAge) continue; + if (act.type === 'pingRole' && channel?.isTextBased() && act.roleId) { + await (channel as TextChannel).send({ content: `<@&${act.roleId}> Bitte Ticket pruefen.` }).catch(() => undefined); + } + if (act.type === 'reminder' && channel?.isTextBased()) { + await (channel as TextChannel) + .send({ content: act.message || 'Reminder: Ticket ist noch offen.' }) + .catch(() => undefined); + } + if (act.type === 'flag' && act.status && ticket.status !== act.status) { + await prisma.ticket.update({ where: { id: ticket.id }, data: { status: act.status } }).catch(() => undefined); + } + } + } + + public startLoop() { + setInterval(() => { + const since = new Date(Date.now() - 24 * 60 * 60 * 1000); + prisma.ticket + .findMany({ + where: { status: { notIn: ['erledigt', 'closed'] }, createdAt: { lte: since } }, + take: 50 + }) + .then((tickets) => tickets.forEach((t) => this.checkTicket(t, true))) + .catch(() => undefined); + }, 60_000); + } +} diff --git a/src/services/ticketService.ts b/src/services/ticketService.ts index 2f8d06e..c43bc35 100644 --- a/src/services/ticketService.ts +++ b/src/services/ticketService.ts @@ -11,15 +11,19 @@ import { GuildMember, PermissionsBitField, TextChannel, - Client + Client, + Message } from 'discord.js'; -import { prisma } from '../database'; import fs from 'fs'; import path from 'path'; -import { TicketRecord } from '../utils/types'; +import { prisma } from '../database'; +import { TicketRecord, TicketStatus } from '../utils/types'; import { logger } from '../utils/logger'; import { settingsStore } from '../config/state'; import { env } from '../config/env'; +import { context } from '../config/context'; + +const PIPELINE_STATUS: TicketStatus[] = ['neu', 'in_bearbeitung', 'warten_auf_user', 'erledigt']; export class TicketService { private categoryName = 'Tickets'; @@ -40,15 +44,22 @@ export class TicketService { return `#${String(n).padStart(4, '0')}`; } + private normalizeStatus(status: string): TicketStatus { + if (PIPELINE_STATUS.includes(status as TicketStatus)) return status as TicketStatus; + if (status === 'open') return 'neu'; + if (status === 'in-progress') return 'in_bearbeitung'; + if (status === 'closed') return 'erledigt'; + return 'neu'; + } + public async createTicket(interaction: ChatInputCommandInteraction): Promise { if (!interaction.guildId || !interaction.guild) return null; - // TODO: TICKETS: Modul-Check ueber BotModuleService kapseln (Dashboard-Toggle statt direktem Flag). if (!this.isEnabled(interaction.guildId)) { - await interaction.reply({ content: 'Tickets sind für diese Guild deaktiviert.', ephemeral: true }); + await interaction.reply({ content: 'Tickets sind für diese Guild deaktiviert.', ephemeral: true }); return null; } const existing = await prisma.ticket.findFirst({ - where: { userId: interaction.user.id, guildId: interaction.guild.id, status: { not: 'closed' } } + where: { userId: interaction.user.id, guildId: interaction.guild.id, status: { notIn: ['closed', 'erledigt'] } } }); if (existing) { await interaction.reply({ content: 'Du hast bereits ein offenes Ticket.', ephemeral: true }); @@ -58,7 +69,6 @@ export class TicketService { } public async handleButton(interaction: ButtonInteraction) { - // TODO: TICKETS: Button-Handling modularisieren und Kategorien aus Dashboard-Konfig laden. if (!interaction.guild) return; if (interaction.customId === 'support:toggle') { await this.toggleSupport(interaction); @@ -66,28 +76,26 @@ export class TicketService { } if (interaction.customId.startsWith('ticket:create:')) { if (!this.isEnabled(interaction.guild.id)) { - await interaction.reply({ content: 'Tickets sind für diese Guild deaktiviert.', ephemeral: true }); + await interaction.reply({ content: 'Tickets sind für diese Guild deaktiviert.', ephemeral: true }); return; } const topic = interaction.customId.split(':')[2] || 'allgemein'; const existing = await prisma.ticket.findFirst({ - where: { userId: interaction.user.id, guildId: interaction.guild.id, status: { not: 'closed' } } + where: { userId: interaction.user.id, guildId: interaction.guild.id, status: { notIn: ['closed', 'erledigt'] } } }); if (existing) { - await interaction.reply({ content: 'Du hast bereits ein offenes Ticket. Bitte schließe es zuerst.', ephemeral: true }); + await interaction.reply({ content: 'Du hast bereits ein offenes Ticket. Bitte schließe es zuerst.', ephemeral: true }); return; } const record = await this.openTicket(interaction.guild, interaction.member as GuildMember, topic); - if (record) { - await interaction.reply({ content: 'Ticket erstellt! Schau im neuen Kanal nach.', ephemeral: true }); - } else { - await interaction.reply({ content: 'Ticket konnte nicht erstellt werden.', ephemeral: true }); - } + await interaction.reply({ + content: record ? 'Ticket erstellt! Schau im neuen Kanal nach.' : 'Ticket konnte nicht erstellt werden.', + ephemeral: true + }); return; } - // TODO: TICKETS: Claim-Flow per Rollen/SLAs konfigurierbar machen und ins Dashboard syncen. if (interaction.customId === 'ticket:claim') { const ticket = await this.getTicketByChannel(interaction); if (!ticket) { @@ -98,19 +106,21 @@ export class TicketService { await interaction.reply({ content: 'Du kannst dein eigenes Ticket nicht claimen.', ephemeral: true }); return; } - await prisma.ticket.update({ where: { id: ticket.id }, data: { claimedBy: interaction.user.id, status: 'in-progress' } }); - await interaction.reply({ content: `${interaction.user} hat das Ticket übernommen.` }); + await prisma.ticket.update({ + where: { id: ticket.id }, + data: { claimedBy: interaction.user.id, status: 'in_bearbeitung', firstClaimAt: ticket.firstClaimAt ?? new Date() } + }); + await interaction.reply({ content: `${interaction.user} hat das Ticket übernommen.` }); + await this.runAutomations({ ...ticket, status: 'in_bearbeitung', guildId: interaction.guild.id }); return; } - // TODO: TICKETS: Close-Flow modularisieren (Feedback, Transcript-Optionen) und Dashboard-Status sofort aktualisieren. if (interaction.customId === 'ticket:close') { const ok = await this.closeTicketButton(interaction); if (!ok) await interaction.reply({ content: 'Ticket konnte nicht geschlossen werden.', ephemeral: true }); return; } - // TODO: TICKETS: Transcript-Export in Storage/DB ablegen und Download-Link ins Dashboard liefern. if (interaction.customId === 'ticket:transcript') { const ticket = await this.getTicketByChannel(interaction); if (!ticket) { @@ -130,18 +140,21 @@ export class TicketService { await interaction.reply({ content: 'Du kannst dein eigenes Ticket nicht claimen.', ephemeral: true }); return true; } - await prisma.ticket.update({ where: { id: ticket.id }, data: { claimedBy: interaction.user.id, status: 'in-progress' } }); - await channel.send({ content: `${interaction.user} hat das Ticket übernommen.` }); + await prisma.ticket.update({ + where: { id: ticket.id }, + data: { claimedBy: interaction.user.id, status: 'in_bearbeitung', firstClaimAt: ticket.firstClaimAt ?? new Date() } + }); + await channel.send({ content: `${interaction.user} hat das Ticket übernommen.` }); + await this.runAutomations({ ...ticket, status: 'in_bearbeitung', guildId: channel.guildId }); return true; } public async closeTicket(interaction: ChatInputCommandInteraction, reason?: string) { - // TODO: TICKETS: Dashboard-Action zum Schliessen mit optionalen Vorlagen/Gründen anbinden und Bot/DB synchronisieren. const channel = interaction.channel as TextChannel; const ticket = await prisma.ticket.findFirst({ where: { channelId: channel.id } }); if (!ticket) return false; const transcriptPath = await this.exportTranscript(channel, ticket.id); - await prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'closed', transcript: transcriptPath } }); + await prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'erledigt', transcript: transcriptPath } }); await channel.send({ content: `Ticket geschlossen. Grund: ${reason ?? 'Kein Grund angegeben'}` }).catch(() => undefined); await this.sendTranscriptToLog(channel.guild, transcriptPath, ticket); await channel.delete('Ticket geschlossen'); @@ -161,10 +174,10 @@ export class TicketService { ); const buttons = new ActionRowBuilder().addComponents( - new ButtonBuilder().setCustomId('ticket:create:ban').setLabel('Ban Einspruch').setEmoji('🛡').setStyle(ButtonStyle.Primary), - new ButtonBuilder().setCustomId('ticket:create:help').setLabel('Discord-Hilfe').setEmoji('🛠').setStyle(ButtonStyle.Secondary), - new ButtonBuilder().setCustomId('ticket:create:feedback').setLabel('Feedback').setEmoji('💬').setStyle(ButtonStyle.Secondary), - new ButtonBuilder().setCustomId('ticket:create:other').setLabel('Allgemeine Frage').setEmoji('❓').setStyle(ButtonStyle.Secondary) + new ButtonBuilder().setCustomId('ticket:create:ban').setLabel('Ban Einspruch').setEmoji('🚫').setStyle(ButtonStyle.Primary), + new ButtonBuilder().setCustomId('ticket:create:help').setLabel('Discord-Hilfe').setEmoji('💬').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId('ticket:create:feedback').setLabel('Feedback').setEmoji('📝').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId('ticket:create:other').setLabel('Allgemeine Frage').setEmoji('❔').setStyle(ButtonStyle.Secondary) ); return { embed, buttons }; @@ -179,17 +192,13 @@ export class TicketService { .setTitle(config.title || 'Ticket Support') .setDescription( config.description || - 'Bitte wähle eine Kategorie, um ein Ticket zu erstellen. Sei respektvoll & sachlich. Missbrauch führt zu Konsequenzen.' + 'Bitte wähle eine Kategorie, um ein Ticket zu erstellen. Sei respektvoll & sachlich. Missbrauch führt zu Konsequenzen.' ) .setColor(0xf97316); const buttons = new ActionRowBuilder(); - // TODO: TICKETS: Panel-Definitionen versionieren und im Dashboard editierbar machen (Labels/Emojis/Permissions). config.categories.slice(0, 5).forEach((cat) => { - const btn = new ButtonBuilder() - .setCustomId(`ticket:create:${cat.customId}`) - .setLabel(cat.label) - .setStyle(ButtonStyle.Secondary); + const btn = new ButtonBuilder().setCustomId(`ticket:create:${cat.customId}`).setLabel(cat.label).setStyle(ButtonStyle.Secondary); if (cat.emoji) btn.setEmoji(cat.emoji); buttons.addComponents(btn); }); @@ -197,8 +206,6 @@ export class TicketService { } public async exportTranscript(channel: TextChannel, ticketId: string) { - // TODO: TICKETS: Transcript-Speicherung abstrahieren (z.B. S3/DB) und Status ans Dashboard senden. - // Discord API limit: max 100 per fetch const messages = await channel.messages.fetch({ limit: 100 }); const lines = messages .sort((a, b) => a.createdTimestamp - b.createdTimestamp) @@ -254,11 +261,10 @@ export class TicketService { const channel = await guild.channels.create({ name: channelName, type: ChannelType.GuildText, - parent: category.id, + parent: category as any, permissionOverwrites: overwrites }); - // TODO: TICKETS: Status/Metadaten beim Erstellen direkt ans Dashboard pushen (WebSocket) und SLA speichern. const record = await prisma.ticket.create({ data: { userId: member.id, @@ -266,14 +272,15 @@ export class TicketService { guildId: guild.id, topic, priority: 'normal', - status: 'open', - ticketNumber: nextNumber + status: 'neu', + ticketNumber: nextNumber, + createdAt: new Date() } }); const embed = new EmbedBuilder() .setTitle(`Ticket: ${topic}`) - .setDescription('Ein Teammitglied wird sich gleich kuemmern. Nutze `/claim`, um den Fall zu uebernehmen.') + .setDescription('Ein Teammitglied wird sich gleich kümmern. Nutze `/claim`, um den Fall zu übernehmen.') .setColor(0x7289da); const controls = new ActionRowBuilder().addComponents( @@ -290,7 +297,7 @@ export class TicketService { allowedMentions: supportMention ? { roles: [supportRoleId as string], users: [member.id] } : { users: [member.id] } }); await this.sendTicketCreatedLog(guild, channel, member, topic, nextNumber, supportRoleId); - + await this.runAutomations({ ...record }); return record as TicketRecord; } @@ -301,20 +308,18 @@ export class TicketService { } private async closeTicketButton(interaction: ButtonInteraction) { - // TODO: TICKETS: Gemeinsamen Close-Pfad fuer Bot/Dashboard extrahieren, um Status-Divergenzen zu vermeiden. const channel = interaction.channel as TextChannel | null; if (!channel) return false; const ticket = await prisma.ticket.findFirst({ where: { channelId: channel.id } }); if (!ticket) return false; const transcriptPath = await this.exportTranscript(channel, ticket.id); - await prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'closed', transcript: transcriptPath } }); + await prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'erledigt', transcript: transcriptPath } }); await interaction.reply({ content: 'Ticket geschlossen.', ephemeral: true }).catch(() => undefined); await this.sendTranscriptToLog(channel.guild, transcriptPath, ticket); await channel.delete('Ticket geschlossen'); return true; } - // TODO: TICKETS: Log-Ziel und Storage ueber Dashboard konfigurierbar machen (z.B. Channel, Forum, Webhook). private async sendTranscriptToLog(guild: Guild, transcriptPath: string, ticket: { id: string; channelId: string }) { const { logChannelId, categories } = this.getLoggingConfig(guild.id); if (!logChannelId || categories.ticketActions === false) return; @@ -322,7 +327,7 @@ export class TicketService { if (!logChannel || !logChannel.isTextBased()) return; try { await (logChannel as any).send({ - content: `Transcript für Ticket ${ticket.id} (${ticket.channelId})`, + content: `Transcript für Ticket ${ticket.id} (${ticket.channelId})`, files: [transcriptPath] }); } catch (err) { @@ -478,8 +483,53 @@ export class TicketService { await (logChannel as any).send({ content: `[Support] ${message}` }).catch(() => undefined); } + public async trackFirstResponse(message: Message) { + if (!message.guildId || !message.channelId || message.author?.bot) return; + const ticket = await prisma.ticket.findFirst({ where: { channelId: message.channelId } }); + if (!ticket || ticket.firstResponseAt) return; + const cfg = settingsStore.get(message.guildId); + const supportRoleId = cfg?.supportRoleId || env.supportRoleId; + const member = message.member as GuildMember | null; + const isSupport = + (supportRoleId && member?.roles.cache.has(supportRoleId)) || + member?.permissions.has(PermissionsBitField.Flags.ManageMessages) || + ticket.userId !== message.author.id; + if (!isSupport) return; + await prisma.ticket.update({ where: { id: ticket.id }, data: { firstResponseAt: new Date() } }); + } + + public async suggestKnowledgeBase(message: Message) { + if (!message.guildId || !message.channelId || message.author?.bot) return; + const ticket = await prisma.ticket.findFirst({ where: { channelId: message.channelId } }); + if (!ticket || ticket.kbSuggestionSentAt) return; + if (ticket.userId !== message.author.id) return; + const matches = await context.knowledgeBase.match(message.guildId, message.content || ''); + if (!matches.length) return; + const channel = message.channel as TextChannel | null; + if (!channel || !channel.isTextBased()) return; + const lines = matches.map((m: any) => `• **${m.title}** – ${m.content}`).join('\n'); + await channel.send({ content: `Ich habe vielleicht passende Lösungen gefunden:\n${lines}` }).catch(() => undefined); + await prisma.ticket.update({ where: { id: ticket.id }, data: { kbSuggestionSentAt: new Date() } }); + } + + public async updateStatus(ticketId: string, status: TicketStatus) { + const normalized = this.normalizeStatus(status); + const ticket = await prisma.ticket.findFirst({ where: { id: ticketId } }); + if (!ticket) return null; + const updated = await prisma.ticket.update({ where: { id: ticketId }, data: { status: normalized } }); + await this.runAutomations(updated); + return updated; + } + + private async runAutomations(ticket: any) { + try { + await context.ticketAutomation.checkTicket(ticket); + } catch (err) { + logger.warn('Automation check failed', err); + } + } + private isEnabled(guildId: string) { - // TODO: MODULE: Modul-Status ueber BotModuleService/SettingsStore vereinheitlichen. const cfg = settingsStore.get(guildId); return cfg?.ticketsEnabled === true; } diff --git a/src/utils/types.ts b/src/utils/types.ts index bbf14af..799e5dc 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -21,11 +21,18 @@ export interface TicketRecord { guildId: string; topic?: string; priority?: 'low' | 'normal' | 'high'; - status: 'open' | 'in-progress' | 'closed'; + status: TicketStatus; claimedBy?: string; transcript?: string; + firstClaimAt?: Date | null; + firstResponseAt?: Date | null; + kbSuggestionSentAt?: Date | null; + createdAt?: Date; + updatedAt?: Date; } +export type TicketStatus = 'neu' | 'in_bearbeitung' | 'warten_auf_user' | 'erledigt' | 'open' | 'in-progress' | 'closed'; + export interface ForumUser { discordId: string; forumUserId: string; diff --git a/src/web/routes/api.ts b/src/web/routes/api.ts index c1d2fa2..a481451 100644 --- a/src/web/routes/api.ts +++ b/src/web/routes/api.ts @@ -151,6 +151,89 @@ router.get('/tickets', requireAuth, async (req, res) => { } }); +router.get('/tickets/pipeline', requireAuth, async (req, res) => { + const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined; + if (!guildId) return res.status(400).json({ error: 'guildId required' }); + const tickets = await prisma.ticket.findMany({ where: { guildId }, orderBy: { createdAt: 'desc' }, take: 200 }); + const grouped = { neu: [], in_bearbeitung: [], warten_auf_user: [], erledigt: [] } as Record; + tickets.forEach((t) => { + const statusVal = ['neu', 'in_bearbeitung', 'warten_auf_user', 'erledigt'].includes(t.status) ? t.status : 'neu'; + (grouped as any)[statusVal].push(t); + }); + res.json({ pipeline: grouped }); +}); + +router.post('/tickets/:id/status', requireAuth, async (req, res) => { + const ticketId = req.params.id; + const statusVal = typeof req.body.status === 'string' ? req.body.status : ''; + if (!statusVal) return res.status(400).json({ error: 'status required' }); + const updated = await context.tickets.updateStatus(ticketId, statusVal as any); + if (!updated) return res.status(404).json({ error: 'not found' }); + res.json({ ticket: updated }); +}); + +router.get('/tickets/sla', requireAuth, async (req, res) => { + const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined; + if (!guildId) return res.status(400).json({ error: 'guildId required' }); + const days = Math.min(Math.max(Number(req.query.days) || 30, 1), 180); + const since = new Date(Date.now() - days * 24 * 3600 * 1000); + const tickets = await prisma.ticket.findMany({ + where: { guildId, createdAt: { gte: since } }, + select: { createdAt: true, firstClaimAt: true, firstResponseAt: true, claimedBy: true } + }); + const supporterStats: Record< + string, + { supporterId: string; count: number; ttcSum: number; ttfrSum: number; ttfrCount: number; ttcCount: number } + > = {}; + const dayStats: Record< + string, + { date: string; count: number; ttcSum: number; ttfrSum: number; ttcCount: number; ttfrCount: number } + > = {}; + tickets.forEach((t) => { + const dayKey = t.createdAt.toISOString().slice(0, 10); + if (!dayStats[dayKey]) dayStats[dayKey] = { date: dayKey, count: 0, ttcSum: 0, ttfrSum: 0, ttcCount: 0, ttfrCount: 0 }; + dayStats[dayKey].count += 1; + if (t.claimedBy && t.firstClaimAt) { + const diff = t.firstClaimAt.getTime() - t.createdAt.getTime(); + const key = t.claimedBy; + if (!supporterStats[key]) + supporterStats[key] = { supporterId: key, count: 0, ttcSum: 0, ttfrSum: 0, ttcCount: 0, ttfrCount: 0 }; + supporterStats[key].count += 1; + supporterStats[key].ttcSum += diff; + supporterStats[key].ttcCount += 1; + dayStats[dayKey].ttcSum += diff; + dayStats[dayKey].ttcCount += 1; + } + if (t.firstResponseAt) { + const diff = t.firstResponseAt.getTime() - t.createdAt.getTime(); + if (t.claimedBy) { + const key = t.claimedBy; + if (!supporterStats[key]) + supporterStats[key] = { supporterId: key, count: 0, ttcSum: 0, ttfrSum: 0, ttcCount: 0, ttfrCount: 0 }; + supporterStats[key].ttfrSum += diff; + supporterStats[key].ttfrCount += 1; + } + dayStats[dayKey].ttfrSum += diff; + dayStats[dayKey].ttfrCount += 1; + } + }); + const supporters = Object.values(supporterStats).map((s) => ({ + supporterId: s.supporterId, + tickets: s.count, + avgTTC: s.ttcCount ? Math.round(s.ttcSum / s.ttcCount) : null, + avgTTFR: s.ttfrCount ? Math.round(s.ttfrSum / s.ttfrCount) : null + })); + const daysArr = Object.values(dayStats) + .sort((a, b) => a.date.localeCompare(b.date)) + .map((d) => ({ + date: d.date, + tickets: d.count, + avgTTC: d.ttcCount ? Math.round(d.ttcSum / d.ttcCount) : null, + avgTTFR: d.ttfrCount ? Math.round(d.ttfrSum / d.ttfrCount) : null + })); + res.json({ supporters, days: daysArr }); +}); + router.get('/tickets/:id/transcript', requireAuth, async (req, res) => { const id = req.params.id; try { @@ -460,6 +543,106 @@ router.delete('/reactionroles/:id', requireAuth, async (req, res) => { } }); +router.get('/automations', requireAuth, async (req, res) => { + const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined; + if (!guildId) return res.status(400).json({ error: 'guildId required' }); + const rules = await context.ticketAutomation.list(guildId); + res.json({ rules }); +}); + +router.post('/automations', requireAuth, async (req, res) => { + const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined; + if (!guildId) return res.status(400).json({ error: 'guildId required' }); + const rule = await context.ticketAutomation.save({ + guildId, + name: req.body.name || 'Automation', + condition: req.body.condition || {}, + action: req.body.action || {}, + active: req.body.active !== false + }); + res.json({ rule }); +}); + +router.put('/automations/:id', requireAuth, async (req, res) => { + const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined; + if (!guildId) return res.status(400).json({ error: 'guildId required' }); + const id = req.params.id; + const rule = await context.ticketAutomation.save({ + id, + guildId, + name: req.body.name || 'Automation', + condition: req.body.condition || {}, + action: req.body.action || {}, + active: req.body.active !== false + }); + res.json({ rule }); +}); + +router.delete('/automations/:id', requireAuth, async (req, res) => { + const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined; + const id = req.params.id; + if (!guildId) return res.status(400).json({ error: 'guildId required' }); + const ok = await context.ticketAutomation.remove(guildId, id); + if (!ok) return res.status(404).json({ error: 'not found' }); + res.json({ ok: true }); +}); + +router.get('/kb', requireAuth, async (req, res) => { + const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined; + if (!guildId) return res.status(400).json({ error: 'guildId required' }); + const articles = await context.knowledgeBase.list(guildId); + res.json({ articles }); +}); + +router.post('/kb', requireAuth, async (req, res) => { + const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined; + if (!guildId) return res.status(400).json({ error: 'guildId required' }); + const keywords = + typeof req.body.keywords === 'string' + ? req.body.keywords + .split(',') + .map((s: string) => s.trim()) + .filter(Boolean) + : req.body.keywords || []; + const article = await context.knowledgeBase.save({ + guildId, + title: req.body.title || 'Artikel', + keywords, + content: req.body.content || '' + }); + res.json({ article }); +}); + +router.put('/kb/:id', requireAuth, async (req, res) => { + const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined; + if (!guildId) return res.status(400).json({ error: 'guildId required' }); + const id = req.params.id; + const keywords = + typeof req.body.keywords === 'string' + ? req.body.keywords + .split(',') + .map((s: string) => s.trim()) + .filter(Boolean) + : req.body.keywords || []; + const article = await context.knowledgeBase.save({ + id, + guildId, + title: req.body.title || 'Artikel', + keywords, + content: req.body.content || '' + }); + res.json({ article }); +}); + +router.delete('/kb/:id', requireAuth, async (req, res) => { + const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined; + const id = req.params.id; + if (!guildId) return res.status(400).json({ error: 'guildId required' }); + const ok = await context.knowledgeBase.remove(guildId, id); + if (!ok) return res.status(404).json({ error: 'not found' }); + res.json({ ok: true }); +}); + router.get('/statuspage', requireAuth, async (req, res) => { const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined; if (!guildId) return res.status(400).json({ error: 'guildId required' });