[deploy] add ticket sla, pipeline, automations, kb
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 36s
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 36s
This commit is contained in:
@@ -40,13 +40,41 @@ model Ticket {
|
|||||||
guildId String
|
guildId String
|
||||||
topic String?
|
topic String?
|
||||||
priority String @default("normal")
|
priority String @default("normal")
|
||||||
status String
|
status String @default("neu")
|
||||||
claimedBy String?
|
claimedBy String?
|
||||||
transcript String?
|
transcript String?
|
||||||
|
firstClaimAt DateTime?
|
||||||
|
firstResponseAt DateTime?
|
||||||
|
kbSuggestionSentAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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 {
|
model Level {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
|
|||||||
132
readme.md
132
readme.md
@@ -1,77 +1,73 @@
|
|||||||
# Papo Discord Bot
|
# Papo Discord Bot
|
||||||
|
|
||||||
## 1. Projektueberblick
|
Discord-Bot (discord.js 14, TypeScript) mit Web-Dashboard, Prisma/PostgreSQL und Docker-Support.
|
||||||
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.
|
|
||||||
|
|
||||||
## 2. Installation / Setup
|
## Was drin ist
|
||||||
Voraussetzungen:
|
- Ticketsystem: Slash-Commands (/ticket, /claim, /close, /ticketpriority, /ticketstatus, /transcript, /ticketpanel), Panels, Transcripts unter `./transcripts`, Support-Login-Panel mit Rollen-Vergabe/On-Duty-Logging.
|
||||||
- Node.js 18+ (CommonJS, ts-node fuer Dev)
|
- Automod: Link-Filter (Whitelist), Spam/Caps-Erkennung, Bad-Word-Listen (Custom), Timeouts, Logging.
|
||||||
- Postgres-Datenbank zugaenglich per Connection-String
|
- Musik: play/skip/stop/pause/resume/loop, Queue, aktivierbar/deaktivierbar pro Guild.
|
||||||
- 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)
|
- Welcome: konfigurierbare Embeds (Channel, Farbe, Texte, Bilder/Uploads), Preview im Dashboard, Text-Fallback.
|
||||||
- OAuth Redirect: `http://localhost:PORT/auth/callback` oder eigener `DASHBOARD_BASE_URL`
|
- 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:
|
## Tech-Stack
|
||||||
1) Repository klonen und ins Projekt wechseln.
|
- Node.js 20 (Docker-Basis), TypeScript (CommonJS)
|
||||||
2) Abhaengigkeiten installieren: npm install.
|
- discord.js 14, play-dl, @discordjs/voice
|
||||||
3) .env aus .env.example kopieren und Variablen setzen (siehe Liste unten).
|
- Express + OAuth2-Login, Prisma ORM (PostgreSQL)
|
||||||
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`).
|
- Dockerfile + docker-compose (App + Postgres)
|
||||||
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.
|
|
||||||
|
|
||||||
Environment-Variablen:
|
## Setup (lokal, Entwicklung)
|
||||||
- DISCORD_TOKEN (Bot Token, Pflicht)
|
1. Repo klonen, in das Verzeichnis wechseln.
|
||||||
- DISCORD_CLIENT_ID (Client ID, Pflicht)
|
2. `cp .env.example .env` und Variablen setzen (siehe unten).
|
||||||
- DISCORD_CLIENT_SECRET (fuer Dashboard-OAuth erforderlich)
|
3. Dependencies installieren: `npm ci` (oder `npm install`).
|
||||||
- DISCORD_GUILD_ID (optionale Einzel-Guild fuer Command-Registrierung)
|
4. Prisma: `npx prisma generate --schema=src/database/schema.prisma` und `npx prisma migrate dev --name init`.
|
||||||
- DISCORD_GUILD_IDS (kommagetrennte Liste fuer mehrere Guilds)
|
5. Start Dev: `npm run dev` (ts-node-dev). Dashboard und Bot laufen auf `PORT` (default 3000).
|
||||||
- DATABASE_URL (Postgres-URL, Pflicht)
|
6. Slash-Commands werden beim Start fuer die IDs in `DISCORD_GUILD_IDS` (oder `DISCORD_GUILD_ID`) registriert.
|
||||||
- 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)
|
|
||||||
|
|
||||||
## 3. Datenbank & Migrationen
|
## Setup mit Docker
|
||||||
- Prisma-Schema: `prisma/schema.prisma` (alias in `src/database/schema.prisma`).
|
- `.dockerignore` blendet lokale node_modules/.env aus.
|
||||||
- Migrationen per `npx prisma migrate dev --name <name>`; danach `npx prisma generate`.
|
- Dev-Stack: `docker-compose up --build` (nutzt `Dockerfile`, Postgres 15, env aus `.env`, `npm run dev` im Container).
|
||||||
- Wesentliche Tabellen:
|
- Eigenes Image: `docker build .` (Prisma-Generate laeuft im Build).
|
||||||
- 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.
|
|
||||||
|
|
||||||
## 4. Module & Features (Details)
|
## Environment-Variablen
|
||||||
- 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.
|
- `DISCORD_TOKEN` (Pflicht, Bot Token)
|
||||||
- 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.
|
- `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` (Pflicht fuer Dashboard-OAuth)
|
||||||
- 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.
|
- `DISCORD_GUILD_ID` (optional Einzel-Guild fuer Commands)
|
||||||
- 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.
|
- `DISCORD_GUILD_IDS` (kommagetrennt, mehrere Guilds)
|
||||||
- Logging: Kategorien joinLeave/messageEdit/messageDelete/automodActions/ticketActions/musicEvents, Log-Channel im Dashboard konfigurierbar; nutzt LoggingService fuer Events.
|
- `DATABASE_URL` (Pflicht, Postgres)
|
||||||
- Leveling: XP pro Nachricht, /rank Anzeige, Toggle ueber Settings. Unique Constraint pro Guild/User.
|
- `PORT` (Webserver/Dashboard, default 3000)
|
||||||
- Dynamische Voice: Lobby-Channel erzeugt private Voice-Channels mit Name-Template, optionalem Userlimit; Benutzer wird automatisch verschoben, leere erzeugte Channels werden entfernt.
|
- `SESSION_SECRET` (Express Session Secret, default `papo_dev_secret`)
|
||||||
- Modulverwaltung: Dashboard-Seite Module mit Toggles fuer ticketsEnabled/automodEnabled/welcomeEnabled/musicEnabled/levelingEnabled; API /api/modules liefert Status pro Guild.
|
- `DASHBOARD_BASE_URL` (Public Base URL, fuer OAuth Redirect)
|
||||||
- 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.
|
- `WEB_BASE_PATH` (Default `/ucp`, ohne Slash am Ende)
|
||||||
- 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).
|
- `OWNER_IDS` (kommagetrennte Owner fuer Admin-UI)
|
||||||
|
- `SUPPORT_ROLE_ID` (optional Ticket/Support-Login Rolle)
|
||||||
|
|
||||||
## 5. Roadmap / Planung
|
## Datenbank / Prisma
|
||||||
- SupportBot: Ansagen, Supportzeiten, Wartemusik (geplant)
|
- Schema: `src/database/schema.prisma` (zweites Schema in `prisma/schema.prisma` fuer Binary Targets).
|
||||||
- Embed-Builder (geplant)
|
- Migrationen: `npx prisma migrate dev --name <name>`; danach `npx prisma generate --schema=src/database/schema.prisma`.
|
||||||
- Team-Systeme: Abwesenheiten melden, Team-Dashboard (geplant)
|
- Kern-Tabellen: GuildSettings (Module/Config), Ticket, TicketSupportSession, Event/EventSignup, Birthday, ReactionRoleSet, Level.
|
||||||
|
|
||||||
## 6. Credits & Lizenz
|
## Kommandos & Scripts
|
||||||
- Autoren/Maintainer: nicht angegeben.
|
- `npm run dev` – Entwicklung (ts-node-dev)
|
||||||
- Lizenz: nicht angegeben (bitte vor Nutzung pruefen).
|
- `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.
|
||||||
|
|||||||
@@ -12,14 +12,15 @@ const command: SlashCommand = {
|
|||||||
.setName('status')
|
.setName('status')
|
||||||
.setDescription('Neuer Status')
|
.setDescription('Neuer Status')
|
||||||
.addChoices(
|
.addChoices(
|
||||||
{ name: 'Offen', value: 'open' },
|
{ name: 'Neu', value: 'neu' },
|
||||||
{ name: 'In Bearbeitung', value: 'in-progress' },
|
{ name: 'In Bearbeitung', value: 'in_bearbeitung' },
|
||||||
{ name: 'Geschlossen', value: 'closed' }
|
{ name: 'Warten auf User', value: 'warten_auf_user' },
|
||||||
|
{ name: 'Erledigt', value: 'erledigt' }
|
||||||
)
|
)
|
||||||
.setRequired(true)
|
.setRequired(true)
|
||||||
),
|
),
|
||||||
async execute(interaction: ChatInputCommandInteraction) {
|
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 } });
|
const ticket = await prisma.ticket.findFirst({ where: { channelId: interaction.channelId } });
|
||||||
if (!ticket) {
|
if (!ticket) {
|
||||||
await interaction.reply({ content: 'Kein Ticket in diesem Kanal.', ephemeral: true });
|
await interaction.reply({ content: 'Kein Ticket in diesem Kanal.', ephemeral: true });
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { StatuspageService } from '../services/statuspageService';
|
|||||||
import { BirthdayService } from '../services/birthdayService';
|
import { BirthdayService } from '../services/birthdayService';
|
||||||
import { ReactionRoleService } from '../services/reactionRoleService';
|
import { ReactionRoleService } from '../services/reactionRoleService';
|
||||||
import { EventService } from '../services/eventService';
|
import { EventService } from '../services/eventService';
|
||||||
|
import { TicketAutomationService } from '../services/ticketAutomationService';
|
||||||
|
import { KnowledgeBaseService } from '../services/knowledgeBaseService';
|
||||||
|
|
||||||
export const context = {
|
export const context = {
|
||||||
client: null as Client | null,
|
client: null as Client | null,
|
||||||
@@ -27,7 +29,9 @@ export const context = {
|
|||||||
statuspage: new StatuspageService(),
|
statuspage: new StatuspageService(),
|
||||||
birthdays: new BirthdayService(),
|
birthdays: new BirthdayService(),
|
||||||
reactionRoles: new ReactionRoleService(),
|
reactionRoles: new ReactionRoleService(),
|
||||||
events: new EventService()
|
events: new EventService(),
|
||||||
|
ticketAutomation: new TicketAutomationService(),
|
||||||
|
knowledgeBase: new KnowledgeBaseService()
|
||||||
};
|
};
|
||||||
|
|
||||||
context.modules.setHooks({
|
context.modules.setHooks({
|
||||||
|
|||||||
@@ -39,13 +39,41 @@ model Ticket {
|
|||||||
guildId String
|
guildId String
|
||||||
topic String?
|
topic String?
|
||||||
priority String @default("normal")
|
priority String @default("normal")
|
||||||
status String
|
status String @default("neu")
|
||||||
claimedBy String?
|
claimedBy String?
|
||||||
transcript String?
|
transcript String?
|
||||||
|
firstClaimAt DateTime?
|
||||||
|
firstResponseAt DateTime?
|
||||||
|
kbSuggestionSentAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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 {
|
model Level {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const event: EventHandler = {
|
|||||||
if (message.guildId) context.admin.trackEvent('message', message.guildId);
|
if (message.guildId) context.admin.trackEvent('message', message.guildId);
|
||||||
if (cfg?.automodEnabled === true) context.automod.checkMessage(message, cfg.automodConfig);
|
if (cfg?.automodEnabled === true) context.automod.checkMessage(message, cfg.automodConfig);
|
||||||
if (cfg?.levelingEnabled === true) context.leveling.handleMessage(message);
|
if (cfg?.levelingEnabled === true) context.leveling.handleMessage(message);
|
||||||
|
// Ticket SLA + KB
|
||||||
|
await context.tickets.trackFirstResponse(message);
|
||||||
|
await context.tickets.suggestKnowledgeBase(message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ async function bootstrap() {
|
|||||||
context.admin.setClient(client);
|
context.admin.setClient(client);
|
||||||
context.statuspage.setClient(client);
|
context.statuspage.setClient(client);
|
||||||
context.tickets.setClient(client);
|
context.tickets.setClient(client);
|
||||||
|
context.ticketAutomation.startLoop();
|
||||||
context.birthdays.setClient(client);
|
context.birthdays.setClient(client);
|
||||||
context.reactionRoles.setClient(client);
|
context.reactionRoles.setClient(client);
|
||||||
context.events.setClient(client);
|
context.events.setClient(client);
|
||||||
|
|||||||
34
src/services/knowledgeBaseService.ts
Normal file
34
src/services/knowledgeBaseService.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/services/ticketAutomationService.ts
Normal file
103
src/services/ticketAutomationService.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,15 +11,19 @@ import {
|
|||||||
GuildMember,
|
GuildMember,
|
||||||
PermissionsBitField,
|
PermissionsBitField,
|
||||||
TextChannel,
|
TextChannel,
|
||||||
Client
|
Client,
|
||||||
|
Message
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
import { prisma } from '../database';
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
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 { logger } from '../utils/logger';
|
||||||
import { settingsStore } from '../config/state';
|
import { settingsStore } from '../config/state';
|
||||||
import { env } from '../config/env';
|
import { env } from '../config/env';
|
||||||
|
import { context } from '../config/context';
|
||||||
|
|
||||||
|
const PIPELINE_STATUS: TicketStatus[] = ['neu', 'in_bearbeitung', 'warten_auf_user', 'erledigt'];
|
||||||
|
|
||||||
export class TicketService {
|
export class TicketService {
|
||||||
private categoryName = 'Tickets';
|
private categoryName = 'Tickets';
|
||||||
@@ -40,15 +44,22 @@ export class TicketService {
|
|||||||
return `#${String(n).padStart(4, '0')}`;
|
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<TicketRecord | null> {
|
public async createTicket(interaction: ChatInputCommandInteraction): Promise<TicketRecord | null> {
|
||||||
if (!interaction.guildId || !interaction.guild) return null;
|
if (!interaction.guildId || !interaction.guild) return null;
|
||||||
// TODO: TICKETS: Modul-Check ueber BotModuleService kapseln (Dashboard-Toggle statt direktem Flag).
|
|
||||||
if (!this.isEnabled(interaction.guildId)) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
const existing = await prisma.ticket.findFirst({
|
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) {
|
if (existing) {
|
||||||
await interaction.reply({ content: 'Du hast bereits ein offenes Ticket.', ephemeral: true });
|
await interaction.reply({ content: 'Du hast bereits ein offenes Ticket.', ephemeral: true });
|
||||||
@@ -58,7 +69,6 @@ export class TicketService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async handleButton(interaction: ButtonInteraction) {
|
public async handleButton(interaction: ButtonInteraction) {
|
||||||
// TODO: TICKETS: Button-Handling modularisieren und Kategorien aus Dashboard-Konfig laden.
|
|
||||||
if (!interaction.guild) return;
|
if (!interaction.guild) return;
|
||||||
if (interaction.customId === 'support:toggle') {
|
if (interaction.customId === 'support:toggle') {
|
||||||
await this.toggleSupport(interaction);
|
await this.toggleSupport(interaction);
|
||||||
@@ -66,28 +76,26 @@ export class TicketService {
|
|||||||
}
|
}
|
||||||
if (interaction.customId.startsWith('ticket:create:')) {
|
if (interaction.customId.startsWith('ticket:create:')) {
|
||||||
if (!this.isEnabled(interaction.guild.id)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const topic = interaction.customId.split(':')[2] || 'allgemein';
|
const topic = interaction.customId.split(':')[2] || 'allgemein';
|
||||||
const existing = await prisma.ticket.findFirst({
|
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) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const record = await this.openTicket(interaction.guild, interaction.member as GuildMember, topic);
|
const record = await this.openTicket(interaction.guild, interaction.member as GuildMember, topic);
|
||||||
if (record) {
|
await interaction.reply({
|
||||||
await interaction.reply({ content: 'Ticket erstellt! Schau im neuen Kanal nach.', ephemeral: true });
|
content: record ? 'Ticket erstellt! Schau im neuen Kanal nach.' : 'Ticket konnte nicht erstellt werden.',
|
||||||
} else {
|
ephemeral: true
|
||||||
await interaction.reply({ content: 'Ticket konnte nicht erstellt werden.', ephemeral: true });
|
});
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: TICKETS: Claim-Flow per Rollen/SLAs konfigurierbar machen und ins Dashboard syncen.
|
|
||||||
if (interaction.customId === 'ticket:claim') {
|
if (interaction.customId === 'ticket:claim') {
|
||||||
const ticket = await this.getTicketByChannel(interaction);
|
const ticket = await this.getTicketByChannel(interaction);
|
||||||
if (!ticket) {
|
if (!ticket) {
|
||||||
@@ -98,19 +106,21 @@ export class TicketService {
|
|||||||
await interaction.reply({ content: 'Du kannst dein eigenes Ticket nicht claimen.', ephemeral: true });
|
await interaction.reply({ content: 'Du kannst dein eigenes Ticket nicht claimen.', ephemeral: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await prisma.ticket.update({ where: { id: ticket.id }, data: { claimedBy: interaction.user.id, status: 'in-progress' } });
|
await prisma.ticket.update({
|
||||||
await interaction.reply({ content: `${interaction.user} hat das Ticket übernommen.` });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: TICKETS: Close-Flow modularisieren (Feedback, Transcript-Optionen) und Dashboard-Status sofort aktualisieren.
|
|
||||||
if (interaction.customId === 'ticket:close') {
|
if (interaction.customId === 'ticket:close') {
|
||||||
const ok = await this.closeTicketButton(interaction);
|
const ok = await this.closeTicketButton(interaction);
|
||||||
if (!ok) await interaction.reply({ content: 'Ticket konnte nicht geschlossen werden.', ephemeral: true });
|
if (!ok) await interaction.reply({ content: 'Ticket konnte nicht geschlossen werden.', ephemeral: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: TICKETS: Transcript-Export in Storage/DB ablegen und Download-Link ins Dashboard liefern.
|
|
||||||
if (interaction.customId === 'ticket:transcript') {
|
if (interaction.customId === 'ticket:transcript') {
|
||||||
const ticket = await this.getTicketByChannel(interaction);
|
const ticket = await this.getTicketByChannel(interaction);
|
||||||
if (!ticket) {
|
if (!ticket) {
|
||||||
@@ -130,18 +140,21 @@ export class TicketService {
|
|||||||
await interaction.reply({ content: 'Du kannst dein eigenes Ticket nicht claimen.', ephemeral: true });
|
await interaction.reply({ content: 'Du kannst dein eigenes Ticket nicht claimen.', ephemeral: true });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
await prisma.ticket.update({ where: { id: ticket.id }, data: { claimedBy: interaction.user.id, status: 'in-progress' } });
|
await prisma.ticket.update({
|
||||||
await channel.send({ content: `${interaction.user} hat das Ticket übernommen.` });
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async closeTicket(interaction: ChatInputCommandInteraction, reason?: string) {
|
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 channel = interaction.channel as TextChannel;
|
||||||
const ticket = await prisma.ticket.findFirst({ where: { channelId: channel.id } });
|
const ticket = await prisma.ticket.findFirst({ where: { channelId: channel.id } });
|
||||||
if (!ticket) return false;
|
if (!ticket) return false;
|
||||||
const transcriptPath = await this.exportTranscript(channel, ticket.id);
|
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 channel.send({ content: `Ticket geschlossen. Grund: ${reason ?? 'Kein Grund angegeben'}` }).catch(() => undefined);
|
||||||
await this.sendTranscriptToLog(channel.guild, transcriptPath, ticket);
|
await this.sendTranscriptToLog(channel.guild, transcriptPath, ticket);
|
||||||
await channel.delete('Ticket geschlossen');
|
await channel.delete('Ticket geschlossen');
|
||||||
@@ -161,10 +174,10 @@ export class TicketService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const buttons = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
const buttons = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
new ButtonBuilder().setCustomId('ticket:create:ban').setLabel('Ban Einspruch').setEmoji('🛡').setStyle(ButtonStyle.Primary),
|
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: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: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:other').setLabel('Allgemeine Frage').setEmoji('❔').setStyle(ButtonStyle.Secondary)
|
||||||
);
|
);
|
||||||
|
|
||||||
return { embed, buttons };
|
return { embed, buttons };
|
||||||
@@ -179,17 +192,13 @@ export class TicketService {
|
|||||||
.setTitle(config.title || 'Ticket Support')
|
.setTitle(config.title || 'Ticket Support')
|
||||||
.setDescription(
|
.setDescription(
|
||||||
config.description ||
|
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);
|
.setColor(0xf97316);
|
||||||
|
|
||||||
const buttons = new ActionRowBuilder<ButtonBuilder>();
|
const buttons = new ActionRowBuilder<ButtonBuilder>();
|
||||||
// TODO: TICKETS: Panel-Definitionen versionieren und im Dashboard editierbar machen (Labels/Emojis/Permissions).
|
|
||||||
config.categories.slice(0, 5).forEach((cat) => {
|
config.categories.slice(0, 5).forEach((cat) => {
|
||||||
const btn = new ButtonBuilder()
|
const btn = new ButtonBuilder().setCustomId(`ticket:create:${cat.customId}`).setLabel(cat.label).setStyle(ButtonStyle.Secondary);
|
||||||
.setCustomId(`ticket:create:${cat.customId}`)
|
|
||||||
.setLabel(cat.label)
|
|
||||||
.setStyle(ButtonStyle.Secondary);
|
|
||||||
if (cat.emoji) btn.setEmoji(cat.emoji);
|
if (cat.emoji) btn.setEmoji(cat.emoji);
|
||||||
buttons.addComponents(btn);
|
buttons.addComponents(btn);
|
||||||
});
|
});
|
||||||
@@ -197,8 +206,6 @@ export class TicketService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async exportTranscript(channel: TextChannel, ticketId: string) {
|
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 messages = await channel.messages.fetch({ limit: 100 });
|
||||||
const lines = messages
|
const lines = messages
|
||||||
.sort((a, b) => a.createdTimestamp - b.createdTimestamp)
|
.sort((a, b) => a.createdTimestamp - b.createdTimestamp)
|
||||||
@@ -254,11 +261,10 @@ export class TicketService {
|
|||||||
const channel = await guild.channels.create({
|
const channel = await guild.channels.create({
|
||||||
name: channelName,
|
name: channelName,
|
||||||
type: ChannelType.GuildText,
|
type: ChannelType.GuildText,
|
||||||
parent: category.id,
|
parent: category as any,
|
||||||
permissionOverwrites: overwrites
|
permissionOverwrites: overwrites
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: TICKETS: Status/Metadaten beim Erstellen direkt ans Dashboard pushen (WebSocket) und SLA speichern.
|
|
||||||
const record = await prisma.ticket.create({
|
const record = await prisma.ticket.create({
|
||||||
data: {
|
data: {
|
||||||
userId: member.id,
|
userId: member.id,
|
||||||
@@ -266,14 +272,15 @@ export class TicketService {
|
|||||||
guildId: guild.id,
|
guildId: guild.id,
|
||||||
topic,
|
topic,
|
||||||
priority: 'normal',
|
priority: 'normal',
|
||||||
status: 'open',
|
status: 'neu',
|
||||||
ticketNumber: nextNumber
|
ticketNumber: nextNumber,
|
||||||
|
createdAt: new Date()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle(`Ticket: ${topic}`)
|
.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);
|
.setColor(0x7289da);
|
||||||
|
|
||||||
const controls = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
const controls = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
@@ -290,7 +297,7 @@ export class TicketService {
|
|||||||
allowedMentions: supportMention ? { roles: [supportRoleId as string], users: [member.id] } : { users: [member.id] }
|
allowedMentions: supportMention ? { roles: [supportRoleId as string], users: [member.id] } : { users: [member.id] }
|
||||||
});
|
});
|
||||||
await this.sendTicketCreatedLog(guild, channel, member, topic, nextNumber, supportRoleId);
|
await this.sendTicketCreatedLog(guild, channel, member, topic, nextNumber, supportRoleId);
|
||||||
|
await this.runAutomations({ ...record });
|
||||||
return record as TicketRecord;
|
return record as TicketRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,20 +308,18 @@ export class TicketService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async closeTicketButton(interaction: ButtonInteraction) {
|
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;
|
const channel = interaction.channel as TextChannel | null;
|
||||||
if (!channel) return false;
|
if (!channel) return false;
|
||||||
const ticket = await prisma.ticket.findFirst({ where: { channelId: channel.id } });
|
const ticket = await prisma.ticket.findFirst({ where: { channelId: channel.id } });
|
||||||
if (!ticket) return false;
|
if (!ticket) return false;
|
||||||
const transcriptPath = await this.exportTranscript(channel, ticket.id);
|
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 interaction.reply({ content: 'Ticket geschlossen.', ephemeral: true }).catch(() => undefined);
|
||||||
await this.sendTranscriptToLog(channel.guild, transcriptPath, ticket);
|
await this.sendTranscriptToLog(channel.guild, transcriptPath, ticket);
|
||||||
await channel.delete('Ticket geschlossen');
|
await channel.delete('Ticket geschlossen');
|
||||||
return true;
|
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 }) {
|
private async sendTranscriptToLog(guild: Guild, transcriptPath: string, ticket: { id: string; channelId: string }) {
|
||||||
const { logChannelId, categories } = this.getLoggingConfig(guild.id);
|
const { logChannelId, categories } = this.getLoggingConfig(guild.id);
|
||||||
if (!logChannelId || categories.ticketActions === false) return;
|
if (!logChannelId || categories.ticketActions === false) return;
|
||||||
@@ -322,7 +327,7 @@ export class TicketService {
|
|||||||
if (!logChannel || !logChannel.isTextBased()) return;
|
if (!logChannel || !logChannel.isTextBased()) return;
|
||||||
try {
|
try {
|
||||||
await (logChannel as any).send({
|
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]
|
files: [transcriptPath]
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -478,8 +483,53 @@ export class TicketService {
|
|||||||
await (logChannel as any).send({ content: `[Support] ${message}` }).catch(() => undefined);
|
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) {
|
private isEnabled(guildId: string) {
|
||||||
// TODO: MODULE: Modul-Status ueber BotModuleService/SettingsStore vereinheitlichen.
|
|
||||||
const cfg = settingsStore.get(guildId);
|
const cfg = settingsStore.get(guildId);
|
||||||
return cfg?.ticketsEnabled === true;
|
return cfg?.ticketsEnabled === true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,18 @@ export interface TicketRecord {
|
|||||||
guildId: string;
|
guildId: string;
|
||||||
topic?: string;
|
topic?: string;
|
||||||
priority?: 'low' | 'normal' | 'high';
|
priority?: 'low' | 'normal' | 'high';
|
||||||
status: 'open' | 'in-progress' | 'closed';
|
status: TicketStatus;
|
||||||
claimedBy?: string;
|
claimedBy?: string;
|
||||||
transcript?: 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 {
|
export interface ForumUser {
|
||||||
discordId: string;
|
discordId: string;
|
||||||
forumUserId: string;
|
forumUserId: string;
|
||||||
|
|||||||
@@ -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<string, any[]>;
|
||||||
|
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) => {
|
router.get('/tickets/:id/transcript', requireAuth, async (req, res) => {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
try {
|
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) => {
|
router.get('/statuspage', requireAuth, async (req, res) => {
|
||||||
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||||
|
|||||||
Reference in New Issue
Block a user