add setup wizard, lockdown, team tasks, embed builder; fix admin dashboard
All checks were successful
Deploy Discord Bot / deploy (push) Successful in -1m11s
SonarQube / sonar (push) Successful in -3s

Four new independent features: /setup (guided onboarding wizard for
module/channel/automod-strength config), /lockdown (raid protection with
per-channel permission snapshot/restore and join-kick), /task (staff task
tracker with status buttons + dashboard view), /embed create (modal-driven
embed builder with optional link button). Also fixes the Admin dashboard,
which always showed "-" because loadAdminData() double-wrapped the overview
response and discarded activity data, plus mismatched field names against
adminService's actual shape (guildCount/activeGuilds24/uptimeMs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 01:02:39 +02:00
parent e708cac790
commit 2ff54970e2
30 changed files with 2500 additions and 57 deletions

View File

@@ -16,6 +16,7 @@ import { MusicPage } from './pages/Music';
import { SettingsPage } from './pages/Settings';
import { ModulesPage } from './pages/Modules';
import { Events } from './pages/Events';
import { Tasks } from './pages/Tasks';
import { Admin } from './pages/Admin';
function AppContent() {
@@ -45,6 +46,7 @@ function AppContent() {
case 'settings': return <SettingsPage />;
case 'modules': return <ModulesPage />;
case 'events': return <Events />;
case 'tasks': return <Tasks />;
case 'admin': return <Admin />;
default: return <Dashboard />;
}

View File

@@ -5,7 +5,7 @@ import {
} from '@heroui/react';
import {
LogOut, PanelLeftClose, PanelLeft, Activity, AudioLines, CalendarDays,
ClipboardList, Home, LogIn, Music, Puzzle, RadioTower, Settings,
ClipboardList, Home, LogIn, ListChecks, Music, Puzzle, RadioTower, Settings,
Shield, Sparkles, Tag, Ticket, Wrench
} from 'lucide-react';
import { useApp } from '../../context/AppContext';
@@ -39,6 +39,7 @@ const navGroups = [
items: [
{ key: 'automod', label: 'Automod', icon: <Shield size={18} /> },
{ key: 'reactionroles', label: 'Reaction Roles', icon: <Tag size={18} /> },
{ key: 'tasks', label: 'Team-Aufgaben', icon: <ListChecks size={18} /> },
]
},
{

View File

@@ -4,7 +4,7 @@ import type {
AppConfig, User, Guild, NavKey, TicketRecord, StatusService,
EventItem, ReactionRoleSet, ModuleItem, LogEntry, SettingsState,
SupportLoginConfig, SupportLoginStatus, RegisterForm, RegisterFormField,
RegisterApplication, MusicSession
RegisterApplication, MusicSession, StaffTask
} from '../types';
const appConfig: AppConfig = (window as any).__PAPO__ || {};
@@ -44,6 +44,8 @@ type AppState = {
appNotes: any[];
appHistory: any[];
noteDraft: string;
tasks: StaffTask[];
taskDraft: { title: string; description: string };
};
type AppContextType = AppState & {
@@ -120,6 +122,10 @@ type AppContextType = AppState & {
openAppDetail: (id: string) => Promise<void>;
setNoteDraft: (v: string) => void;
addAppNote: () => Promise<void>;
setTaskDraft: (s: any | ((prev: any) => any)) => void;
createTask: () => Promise<void>;
updateTaskStatus: (id: string, status: string) => Promise<void>;
deleteTask: (id: string) => Promise<void>;
};
const AppContext = createContext<AppContextType | null>(null);
@@ -175,6 +181,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
const [appNotes, setAppNotes] = useState<any[]>([]);
const [appHistory, setAppHistory] = useState<any[]>([]);
const [noteDraft, setNoteDraft] = useState('');
const [tasks, setTasks] = useState<StaffTask[]>([]);
const [taskDraft, setTaskDraft] = useState({ title: '', description: '' });
const setSection = useCallback((key: NavKey) => {
setSectionState(key);
@@ -225,7 +233,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
try {
const [guildInfoRes, overviewRes, activityRes, logsRes, settingsRes, modulesRes,
birthdayRes, reactionRes, statusRes, statsRes, eventsRes, supportLoginRes,
registerFormsRes, registerAppsRes] = await Promise.all([
registerFormsRes, registerAppsRes, tasksRes] = await Promise.all([
apiFetch<any>(`/guild/info?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/overview?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/guild/activity?guildId=${encodeURIComponent(guildId)}`),
@@ -239,7 +247,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
apiFetch<any>(`/events?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/tickets/support-login?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/register/forms?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/register/apps?guildId=${encodeURIComponent(guildId)}`)
apiFetch<any>(`/register/apps?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/tasks?guildId=${encodeURIComponent(guildId)}`)
]);
setGuildInfo(guildInfoRes.guild || null);
setOverview(overviewRes);
@@ -258,6 +267,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
setSupportLogin(supportLoginRes);
setRegisterForms(registerFormsRes.forms || []);
setRegisterApps(registerAppsRes.applications || []);
setTasks(tasksRes.tasks || []);
setReactionDraft({ title: '', channelId: '', entries: '' });
await Promise.all([loadTicketData(guildId), loadAdminData()]);
setStatusMessage('');
@@ -267,12 +277,12 @@ export function AppProvider({ children }: { children: ReactNode }) {
async function loadAdminData() {
if (!user?.isAdmin) return;
try {
const [overviewRes, , logsRes] = await Promise.all([
const [overviewRes, activityRes, logsRes] = await Promise.all([
apiFetch<any>('/admin/overview'),
apiFetch<any>('/admin/activity'),
apiFetch<any>('/admin/logs')
]);
setAdmin({ overview: overviewRes, activity: null, logs: logsRes.logs || [] });
setAdmin({ overview: overviewRes.overview || {}, activity: activityRes.points || [], logs: logsRes.logs || [] });
} catch {}
}
@@ -541,6 +551,28 @@ export function AppProvider({ children }: { children: ReactNode }) {
setAppNotes(notesRes.notes || []);
}
async function createTask() {
if (!taskDraft.title.trim() || !currentGuildId) return;
await apiFetch('/tasks', {
method: 'POST',
body: JSON.stringify({ guildId: currentGuildId, title: taskDraft.title.trim(), description: taskDraft.description.trim() || undefined })
});
setTaskDraft({ title: '', description: '' });
setStatusMessage('Aufgabe erstellt');
await loadGuildData(currentGuildId);
}
async function updateTaskStatus(id: string, status: string) {
await apiFetch(`/tasks/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) });
await loadGuildData(currentGuildId);
}
async function deleteTask(id: string) {
await apiFetch(`/tasks/${id}`, { method: 'DELETE', body: JSON.stringify({ guildId: currentGuildId }) });
setStatusMessage('Aufgabe gelöscht');
await loadGuildData(currentGuildId);
}
const handleLogout = useCallback(() => {
window.location.href = `${appConfig.baseAuth || '/auth'}/logout`;
}, []);
@@ -555,7 +587,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
formDraft, editingFormId, registerTab, statusServiceDraft, statsItemDraft,
ticketDetail, ticketMessages, kbEditDraft, automationEditDraft,
automodStrikes, registerStatusFilter, registerFormFilter, selectedAppId,
appNotes, appHistory, noteDraft,
appNotes, appHistory, noteDraft, tasks, taskDraft,
setCurrentGuildId, setSection, setSettings, setBirthday, setSupportLogin,
setStatusDraft, setStatsDraft, setStatusMessage, loadGuildData,
saveSettingsPayload, saveBirthday, saveStatuspage, saveServerStats,
@@ -570,6 +602,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
setAutomationEditDraft,
loadAutomodStrikes, resetAutomodStrike, setRegisterStatusFilter, setRegisterFormFilter,
loadRegisterApps, openAppDetail, setNoteDraft, addAppNote,
setTaskDraft, createTask, updateTaskStatus, deleteTask,
}}>
{children}
</AppContext.Provider>

View File

@@ -1,43 +1,54 @@
import { Card, CardContent, CardHeader, Chip } from '@heroui/react';
import { Wrench, Activity, Clock, Server, Terminal } from 'lucide-react';
import { Activity, Clock, Server, Terminal } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { formatDate } from '../utils/formatters';
import { StatCard } from '../components/shared/StatCard';
import { BarComparisonChart } from '../components/shared/BarComparisonChart';
import { formatDate, formatDuration } from '../utils/formatters';
const LEVEL_COLORS: Record<string, 'accent' | 'warning' | 'danger'> = {
INFO: 'accent',
WARN: 'warning',
ERROR: 'danger',
};
export function Admin() {
const { user, admin } = useApp();
if (!user?.isAdmin) return null;
const overview = admin.overview || {};
const activityPoints: { hour: string; count: number }[] = admin.activity || [];
const logs: { timestamp: number; level: string; message: string; guildId?: string; category?: string }[] = admin.logs || [];
const chartItems = activityPoints.slice(-12).map((p) => ({
label: new Date(p.hour).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }),
value: p.count
}));
return (
<SectionCard title="Admin" subtitle="Bot-weite Übersichten">
<div className="grid gap-5 xl:grid-cols-2">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard icon={<Server size={18} />} label="Guilds" value={overview.guildCount ?? '-'} color="accent" />
<StatCard icon={<Activity size={18} />} label="Aktive Guilds (24h)" value={overview.activeGuilds24 ?? '-'} color="success" />
<StatCard icon={<Clock size={18} />} label="Uptime" value={formatDuration(overview.uptimeMs)} color="warning" />
<StatCard icon={<Terminal size={18} />} label="Log-Einträge" value={logs.length} />
</div>
<div className="mt-5 grid gap-5 xl:grid-cols-2">
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Bot Overview</h3>
<h3 className="text-base font-semibold">Aktivität (letzte Stunden)</h3>
</CardHeader>
<CardContent className="flex flex-col gap-3 p-5">
<div className="bg-surface-tertiary flex items-center justify-between rounded-xl px-4 py-3 text-sm">
<div className="flex items-center gap-2">
<Server size={14} className="text-accent" />
<span className="text-muted">Guilds</span>
<CardContent className="p-5">
{chartItems.length ? (
<BarComparisonChart items={chartItems} />
) : (
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted">
<Activity size={24} />
Noch keine Aktivität erfasst
</div>
<span className="font-semibold">{admin.overview?.guilds ?? '-'}</span>
</div>
<div className="bg-surface-tertiary flex items-center justify-between rounded-xl px-4 py-3 text-sm">
<div className="flex items-center gap-2">
<Activity size={14} className="text-success" />
<span className="text-muted">Aktive Guilds (24h)</span>
</div>
<span className="font-semibold">{admin.overview?.activeGuilds ?? '-'}</span>
</div>
<div className="bg-surface-tertiary flex items-center justify-between rounded-xl px-4 py-3 text-sm">
<div className="flex items-center gap-2">
<Clock size={14} className="text-warning" />
<span className="text-muted">Uptime</span>
</div>
<span className="font-semibold">{admin.overview?.uptime ?? '-'}</span>
</div>
)}
</CardContent>
</Card>
@@ -45,15 +56,19 @@ export function Admin() {
<CardHeader className="flex items-center justify-between px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Letzte Admin Logs</h3>
<Chip size="sm" variant="soft" color="warning">
{(admin.logs || []).length} Einträge
{logs.length} Einträge
</Chip>
</CardHeader>
<CardContent className="flex flex-col gap-2 p-5">
{(admin.logs || []).length ? (admin.logs || []).slice(0, 20).map((log, i) => (
<CardContent className="flex max-h-96 flex-col gap-2 overflow-y-auto p-5">
{logs.length ? logs.slice(0, 30).map((log, i) => (
<div key={i} className="bg-surface-tertiary flex items-start gap-3 rounded-xl px-4 py-3 text-sm">
<Terminal size={14} className="mt-0.5 text-muted shrink-0" />
<div className="min-w-0">
<p className="text-muted">{log.message || '-'}</p>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Chip size="sm" variant="soft" color={LEVEL_COLORS[log.level] || 'accent'}>{log.level}</Chip>
{log.category && <span className="text-xs text-muted">{log.category}</span>}
</div>
<p className="mt-1 text-muted">{log.message || '-'}</p>
<p className="text-xs text-muted mt-0.5">{formatDate(log.timestamp)}</p>
</div>
</div>

View File

@@ -0,0 +1,80 @@
import { Card, CardContent, CardHeader, Input, TextArea, Button, Chip, TextField, Label } from '@heroui/react';
import { ListChecks, Trash2, Plus } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { formatDate } from '../utils/formatters';
const STATUS_LABELS: Record<string, string> = { open: 'Offen', in_progress: 'In Bearbeitung', done: 'Erledigt' };
const STATUS_COLORS: Record<string, 'warning' | 'accent' | 'success'> = { open: 'warning', in_progress: 'accent', done: 'success' };
export function Tasks() {
const { tasks, taskDraft, setTaskDraft, createTask, updateTaskStatus, deleteTask } = useApp();
return (
<SectionCard title="Team-Aufgaben" subtitle="Aufgaben für dein Team erstellen und Status pflegen.">
<div className="grid gap-5 xl:grid-cols-[1fr_360px]">
<div>
<h3 className="mb-3 text-base font-semibold">Aufgaben ({tasks.length})</h3>
<div className="space-y-3">
{tasks.length ? tasks.map((t) => (
<Card key={t.id}>
<CardContent className="flex flex-col gap-3 p-4">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="font-semibold truncate">{t.title}</div>
<div className="text-xs text-muted">von {t.createdByTag} · {formatDate(t.createdAt)}</div>
</div>
<Button isIconOnly size="sm" variant="danger-soft" onPress={() => deleteTask(t.id)}>
<Trash2 size={14} />
</Button>
</div>
{t.description && <p className="text-sm text-muted">{t.description}</p>}
<div className="flex flex-wrap items-center gap-2">
<select
className="rounded-xl px-3 py-2 text-sm"
value={t.status}
onChange={(e) => updateTaskStatus(t.id, e.target.value)}
>
<option value="open">Offen</option>
<option value="in_progress">In Bearbeitung</option>
<option value="done">Erledigt</option>
</select>
<Chip size="sm" variant="soft" color={STATUS_COLORS[t.status] || 'warning'}>
{STATUS_LABELS[t.status] || t.status}
</Chip>
{t.assigneeId && <span className="text-xs text-muted">Zugewiesen an User-ID {t.assigneeId}</span>}
</div>
</CardContent>
</Card>
)) : (
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted">
<ListChecks size={24} />
Keine Aufgaben
</div>
)}
</div>
</div>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Neue Aufgabe</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<TextField>
<Label>Titel</Label>
<Input value={taskDraft.title} onChange={(e) => setTaskDraft((s) => ({ ...s, title: e.target.value }))} />
</TextField>
<TextField>
<Label>Beschreibung</Label>
<TextArea rows={4} value={taskDraft.description} onChange={(e) => setTaskDraft((s) => ({ ...s, description: e.target.value }))} />
</TextField>
<p className="text-xs text-muted">Eine zuständige Person lässt sich aktuell nur über <code>/task create</code> in Discord zuweisen.</p>
<Button variant="primary" onPress={createTask} isDisabled={!taskDraft.title.trim()}>
<Plus size={16} /> Aufgabe erstellen
</Button>
</CardContent>
</Card>
</div>
</SectionCard>
);
}

View File

@@ -34,6 +34,7 @@ export type NavKey =
| 'settings'
| 'modules'
| 'events'
| 'tasks'
| 'admin';
export type TicketRecord = {
@@ -141,6 +142,19 @@ export type RegisterApplication = {
createdAt?: string;
};
export type StaffTask = {
id: string;
guildId: string;
title: string;
description?: string | null;
status: 'open' | 'in_progress' | 'done';
assigneeId?: string | null;
createdBy: string;
createdByTag: string;
createdAt?: string;
updatedAt?: string;
};
export type MusicSession = {
guildId: string;
nowPlaying?: { title: string; url: string } | null;

View File

@@ -7,6 +7,19 @@ export function formatDate(value?: string | number | null) {
return `${date.toLocaleDateString('de-DE')} ${date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
}
export function formatDuration(ms?: number | null) {
if (!ms || ms < 0) return '-';
const totalMinutes = Math.floor(ms / 60000);
const days = Math.floor(totalMinutes / (60 * 24));
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
const minutes = totalMinutes % 60;
const parts: string[] = [];
if (days) parts.push(`${days}d`);
if (hours) parts.push(`${hours}h`);
if (minutes || !parts.length) parts.push(`${minutes}m`);
return parts.join(' ');
}
export function guildIconUrl(guild?: Guild | null) {
if (!guild) return undefined;
if (guild.icon) return `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png`;

24
node_modules/.prisma/client/edge.js generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -145,6 +145,8 @@ exports.Prisma.GuildSettingsScalarFieldEnum = {
registerConfig: 'registerConfig',
serverStatsEnabled: 'serverStatsEnabled',
serverStatsConfig: 'serverStatsConfig',
lockdownConfig: 'lockdownConfig',
tasksEnabled: 'tasksEnabled',
supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt',
createdAt: 'createdAt'
@@ -314,6 +316,19 @@ exports.Prisma.AutomodStrikeScalarFieldEnum = {
createdAt: 'createdAt'
};
exports.Prisma.StaffTaskScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
title: 'title',
description: 'description',
status: 'status',
assigneeId: 'assigneeId',
createdBy: 'createdBy',
createdByTag: 'createdByTag',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -361,7 +376,8 @@ exports.Prisma.ModelName = {
RegisterApplication: 'RegisterApplication',
RegisterApplicationAnswer: 'RegisterApplicationAnswer',
RegisterApplicationNote: 'RegisterApplicationNote',
AutomodStrike: 'AutomodStrike'
AutomodStrike: 'AutomodStrike',
StaffTask: 'StaffTask'
};
/**

1305
node_modules/.prisma/client/index.d.ts generated vendored

File diff suppressed because it is too large Load Diff

24
node_modules/.prisma/client/index.js generated vendored

File diff suppressed because one or more lines are too long

18
node_modules/.prisma/client/wasm.js generated vendored
View File

@@ -145,6 +145,8 @@ exports.Prisma.GuildSettingsScalarFieldEnum = {
registerConfig: 'registerConfig',
serverStatsEnabled: 'serverStatsEnabled',
serverStatsConfig: 'serverStatsConfig',
lockdownConfig: 'lockdownConfig',
tasksEnabled: 'tasksEnabled',
supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt',
createdAt: 'createdAt'
@@ -314,6 +316,19 @@ exports.Prisma.AutomodStrikeScalarFieldEnum = {
createdAt: 'createdAt'
};
exports.Prisma.StaffTaskScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
title: 'title',
description: 'description',
status: 'status',
assigneeId: 'assigneeId',
createdBy: 'createdBy',
createdByTag: 'createdByTag',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -361,7 +376,8 @@ exports.Prisma.ModelName = {
RegisterApplication: 'RegisterApplication',
RegisterApplicationAnswer: 'RegisterApplicationAnswer',
RegisterApplicationNote: 'RegisterApplicationNote',
AutomodStrike: 'AutomodStrike'
AutomodStrike: 'AutomodStrike',
StaffTask: 'StaffTask'
};
/**

View File

@@ -0,0 +1,48 @@
import { ChatInputCommandInteraction, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,
data: new SlashCommandBuilder()
.setName('lockdown')
.setDescription('Aktiviert oder deaktiviert den Raid-Schutz/Lockdown-Modus.')
.addSubcommand((sub) =>
sub
.setName('enable')
.setDescription('Aktiviert den Lockdown-Modus.')
.addStringOption((opt) => opt.setName('reason').setDescription('Grund für den Lockdown'))
.addRoleOption((opt) => opt.setName('staff_role').setDescription('Rolle, die im Raid-Log gepingt wird'))
)
.addSubcommand((sub) => sub.setName('disable').setDescription('Hebt den Lockdown-Modus wieder auf.'))
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
async execute(interaction: ChatInputCommandInteraction) {
if (!interaction.guild) return;
const sub = interaction.options.getSubcommand();
if (sub === 'enable') {
if (context.lockdown.isActive(interaction.guildId!)) {
await interaction.reply({ content: 'Lockdown ist bereits aktiv.', ephemeral: true });
return;
}
await interaction.deferReply({ ephemeral: true });
const reason = interaction.options.getString('reason') ?? undefined;
const staffRole = interaction.options.getRole('staff_role');
await context.lockdown.enable(interaction.guild, interaction.user.id, reason, staffRole?.id);
await interaction.editReply({ content: 'Lockdown wurde aktiviert. Alle Textkanäle sind für @everyone gesperrt.' });
return;
}
if (sub === 'disable') {
if (!context.lockdown.isActive(interaction.guildId!)) {
await interaction.reply({ content: 'Lockdown ist aktuell nicht aktiv.', ephemeral: true });
return;
}
await interaction.deferReply({ ephemeral: true });
await context.lockdown.disable(interaction.guild);
await interaction.editReply({ content: 'Lockdown wurde aufgehoben.' });
}
}
};
export default command;

View File

@@ -0,0 +1,17 @@
import { ChatInputCommandInteraction, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,
data: new SlashCommandBuilder()
.setName('embed')
.setDescription('Erstellt ein eigenes Embed.')
.addSubcommand((sub) => sub.setName('create').setDescription('Öffnet den Embed-Builder.'))
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),
async execute(interaction: ChatInputCommandInteraction) {
await context.embedBuilder.openModal(interaction);
}
};
export default command;

View File

@@ -0,0 +1,16 @@
import { ChatInputCommandInteraction, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,
data: new SlashCommandBuilder()
.setName('setup')
.setDescription('Startet den Einrichtungsassistenten für diesen Server.')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild),
async execute(interaction: ChatInputCommandInteraction) {
await context.setup.start(interaction);
}
};
export default command;

View File

@@ -0,0 +1,66 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ChatInputCommandInteraction,
EmbedBuilder,
PermissionFlagsBits,
SlashCommandBuilder
} from 'discord.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const STATUS_LABELS: Record<string, string> = { open: 'Offen', in_progress: 'In Bearbeitung', done: 'Erledigt' };
export function buildTaskCard(task: { id: string; title: string; description: string | null; status: string; assigneeId: string | null; createdByTag: string }) {
const embed = new EmbedBuilder()
.setTitle(task.title)
.setDescription(task.description || 'Keine Beschreibung')
.setColor(0xf97316)
.addFields(
{ name: 'Status', value: STATUS_LABELS[task.status] || task.status, inline: true },
{ name: 'Zugewiesen an', value: task.assigneeId ? `<@${task.assigneeId}>` : 'Niemand', inline: true },
{ name: 'Erstellt von', value: task.createdByTag, inline: true }
);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId(`task:status:${task.id}:open`).setLabel('Offen').setStyle(task.status === 'open' ? ButtonStyle.Primary : ButtonStyle.Secondary),
new ButtonBuilder().setCustomId(`task:status:${task.id}:in_progress`).setLabel('In Bearbeitung').setStyle(task.status === 'in_progress' ? ButtonStyle.Primary : ButtonStyle.Secondary),
new ButtonBuilder().setCustomId(`task:status:${task.id}:done`).setLabel('Erledigt').setStyle(task.status === 'done' ? ButtonStyle.Success : ButtonStyle.Secondary)
);
return { embeds: [embed], components: [row] };
}
const command: SlashCommand = {
guildOnly: true,
data: new SlashCommandBuilder()
.setName('task')
.setDescription('Verwaltet Team-Aufgaben.')
.addSubcommand((sub) =>
sub
.setName('create')
.setDescription('Erstellt eine neue Team-Aufgabe.')
.addStringOption((opt) => opt.setName('title').setDescription('Titel der Aufgabe').setRequired(true))
.addStringOption((opt) => opt.setName('description').setDescription('Beschreibung'))
.addUserOption((opt) => opt.setName('assignee').setDescription('Zuständige Person'))
)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),
async execute(interaction: ChatInputCommandInteraction) {
if (!interaction.guildId) return;
const title = interaction.options.getString('title', true);
const description = interaction.options.getString('description') ?? undefined;
const assignee = interaction.options.getUser('assignee');
const task = await context.tasks.createTask({
guildId: interaction.guildId,
title,
description,
assigneeId: assignee?.id,
createdBy: interaction.user.id,
createdByTag: interaction.user.tag
});
await interaction.reply(buildTaskCard(task));
}
};
export default command;

View File

@@ -16,8 +16,14 @@ import { TicketAutomationService } from '../services/ticketAutomationService';
import { KnowledgeBaseService } from '../services/knowledgeBaseService';
import { RegisterService } from '../services/registerService';
import { StatsService } from '../services/statsService';
import { LockdownService } from '../services/lockdownService';
import { TaskService } from '../services/taskService';
import { SetupWizardService } from '../services/setupService';
import { EmbedBuilderService } from '../services/embedBuilderService';
const logging = new LoggingService();
const moduleService = new BotModuleService();
const ticketService = new TicketService();
export const context = {
client: null as Client | null,
@@ -25,10 +31,10 @@ export const context = {
logging,
automod: new AutoModService(logging, true, true),
music: new MusicService(),
tickets: new TicketService(),
tickets: ticketService,
leveling: new LevelService(),
dynamicVoice: new DynamicVoiceService(),
modules: new BotModuleService(),
modules: moduleService,
admin: new AdminService(),
statuspage: new StatuspageService(),
birthdays: new BirthdayService(),
@@ -37,7 +43,11 @@ export const context = {
ticketAutomation: new TicketAutomationService(),
knowledgeBase: new KnowledgeBaseService(),
register: new RegisterService(),
stats: new StatsService()
stats: new StatsService(),
lockdown: new LockdownService(),
tasks: new TaskService(),
setup: new SetupWizardService(moduleService, ticketService),
embedBuilder: new EmbedBuilderService()
};
context.modules.setHooks({

View File

@@ -77,6 +77,15 @@ export interface GuildSettings {
};
supportRoleId?: string;
welcomeEnabled?: boolean;
tasksEnabled?: boolean;
lockdownConfig?: {
active?: boolean;
activatedAt?: string;
activatedBy?: string;
reason?: string;
staffRoleId?: string;
snapshot?: { channelId: string; sendMessages: boolean | null }[];
};
}
class SettingsStore {
@@ -95,7 +104,8 @@ class SettingsStore {
'birthdayEnabled',
'reactionRolesEnabled',
'eventsEnabled',
'registerEnabled'
'registerEnabled',
'tasksEnabled'
] as const;
defaultOn.forEach((key) => {
if (normalized[key] === undefined) normalized[key] = true;
@@ -136,6 +146,8 @@ class SettingsStore {
registerConfig: (row as any).registerConfig ?? undefined,
serverStatsEnabled: (row as any).serverStatsEnabled ?? undefined,
serverStatsConfig: (row as any).serverStatsConfig ?? undefined,
lockdownConfig: (row as any).lockdownConfig ?? undefined,
tasksEnabled: (row as any).tasksEnabled ?? undefined,
supportRoleId: row.supportRoleId ?? undefined
} satisfies GuildSettings;
this.cache.set(row.guildId, this.applyModuleDefaults(cfg));
@@ -191,6 +203,9 @@ class SettingsStore {
if (partial.reactionRolesConfig) {
merged.reactionRolesConfig = { ...(merged.reactionRolesConfig ?? {}), ...partial.reactionRolesConfig };
}
if (partial.lockdownConfig) {
merged.lockdownConfig = { ...(merged.lockdownConfig ?? {}), ...partial.lockdownConfig };
}
merged.automodConfig = { ...mergedAutomod, supportLoginConfig: merged.supportLoginConfig ?? mergedAutomod['supportLoginConfig'] };
merged.statuspageEnabled = mergedAutomod.statuspageEnabled;
merged.statuspageConfig = mergedAutomod.statuspageConfig;
@@ -220,6 +235,8 @@ class SettingsStore {
registerConfig: merged.registerConfig ?? Prisma.JsonNull,
serverStatsEnabled: (merged as any).serverStatsEnabled ?? null,
serverStatsConfig: (merged as any).serverStatsConfig ?? null,
lockdownConfig: merged.lockdownConfig ?? Prisma.JsonNull,
tasksEnabled: merged.tasksEnabled ?? null,
supportRoleId: merged.supportRoleId ?? null
},
create: {
@@ -244,6 +261,8 @@ class SettingsStore {
registerConfig: merged.registerConfig ?? Prisma.JsonNull,
serverStatsEnabled: (merged as any).serverStatsEnabled ?? null,
serverStatsConfig: (merged as any).serverStatsConfig ?? null,
lockdownConfig: merged.lockdownConfig ?? Prisma.JsonNull,
tasksEnabled: merged.tasksEnabled ?? null,
supportRoleId: merged.supportRoleId ?? null
}
});

View File

@@ -0,0 +1,22 @@
-- AlterTable
ALTER TABLE "GuildSettings" ADD COLUMN "lockdownConfig" JSONB,
ADD COLUMN "tasksEnabled" BOOLEAN;
-- CreateTable
CREATE TABLE "StaffTask" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"status" TEXT NOT NULL DEFAULT 'open',
"assigneeId" TEXT,
"createdBy" TEXT NOT NULL,
"createdByTag" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "StaffTask_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "StaffTask_guildId_status_idx" ON "StaffTask"("guildId", "status");

View File

@@ -30,6 +30,8 @@ model GuildSettings {
registerConfig Json?
serverStatsEnabled Boolean?
serverStatsConfig Json?
lockdownConfig Json?
tasksEnabled Boolean?
supportRoleId String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
@@ -242,3 +244,18 @@ model AutomodStrike {
@@index([guildId, userId])
}
model StaffTask {
id String @id @default(cuid())
guildId String
title String
description String?
status String @default("open")
assigneeId String?
createdBy String
createdByTag String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([guildId, status])
}

View File

@@ -6,6 +6,11 @@ import { settingsStore } from '../config/state';
const event: EventHandler = {
name: 'guildMemberAdd',
execute(member: GuildMember) {
if (context.lockdown.isActive(member.guild.id)) {
member.kick('Lockdown aktiv - neue Mitglieder werden abgewiesen').catch(() => undefined);
context.logging.logMemberJoin(member);
return;
}
const guildConfig = settingsStore.get(member.guild.id);
const welcomeCfg = guildConfig?.welcomeConfig || guildConfig?.automodConfig?.welcomeConfig;
if (welcomeCfg?.enabled && welcomeCfg.channelId) {

View File

@@ -1,6 +1,7 @@
import { Interaction } from 'discord.js';
import { EventHandler } from '../utils/types';
import { context } from '../config/context';
import { buildTaskCard } from '../commands/utility/task';
const event: EventHandler = {
name: 'interactionCreate',
@@ -12,6 +13,23 @@ const event: EventHandler = {
return;
}
if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isChannelSelectMenu()) {
if (interaction.customId.startsWith('setup:')) {
await context.setup.handleComponent(interaction as any);
return;
}
if (interaction.customId.startsWith('embed:')) {
await context.embedBuilder.handleComponent(interaction as any);
return;
}
if (interaction.isButton() && interaction.customId.startsWith('task:status:')) {
const [, , taskId, status] = interaction.customId.split(':');
const task = await context.tasks.updateStatus(taskId, status as any);
await interaction.update(buildTaskCard(task));
return;
}
}
if (interaction.isButton()) {
if (interaction.customId.startsWith('event:')) {
const [_, action, eventId] = interaction.customId.split(':');
@@ -32,6 +50,10 @@ const event: EventHandler = {
await context.register.handleModal(interaction as any);
return;
}
if (interaction.customId.startsWith('embed:')) {
await context.embedBuilder.handleModal(interaction);
return;
}
}
}
};

View File

@@ -34,7 +34,9 @@ export class CommandHandler {
birthday: 'birthdayEnabled',
// Events
event: 'eventsEnabled',
events: 'eventsEnabled'
events: 'eventsEnabled',
// Tasks
task: 'tasksEnabled'
};
constructor(private client: Client, private admin?: AdminService, private statuspage?: StatuspageService) {}

View File

@@ -0,0 +1,166 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonInteraction,
ButtonStyle,
ChannelSelectMenuBuilder,
ChannelSelectMenuInteraction,
ChannelType,
ChatInputCommandInteraction,
EmbedBuilder,
ModalBuilder,
ModalSubmitInteraction,
TextInputBuilder,
TextInputStyle
} from 'discord.js';
type ComponentInteraction = ButtonInteraction | ChannelSelectMenuInteraction;
interface EmbedDraft {
guildId: string;
title?: string;
description?: string;
color?: number;
imageUrl?: string;
footer?: string;
buttonLabel?: string;
buttonUrl?: string;
channelId?: string;
}
export class EmbedBuilderService {
private drafts = new Map<string, EmbedDraft>();
public async openModal(interaction: ChatInputCommandInteraction) {
const modal = new ModalBuilder().setCustomId('embed:create').setTitle('Embed erstellen');
const title = new TextInputBuilder().setCustomId('title').setLabel('Titel').setStyle(TextInputStyle.Short).setRequired(false);
const description = new TextInputBuilder().setCustomId('description').setLabel('Beschreibung').setStyle(TextInputStyle.Paragraph).setRequired(false);
const color = new TextInputBuilder().setCustomId('color').setLabel('Farbe (Hex, z.B. #f97316)').setStyle(TextInputStyle.Short).setRequired(false);
const image = new TextInputBuilder().setCustomId('image').setLabel('Bild-URL').setStyle(TextInputStyle.Short).setRequired(false);
const footer = new TextInputBuilder().setCustomId('footer').setLabel('Footer').setStyle(TextInputStyle.Short).setRequired(false);
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(title),
new ActionRowBuilder<TextInputBuilder>().addComponents(description),
new ActionRowBuilder<TextInputBuilder>().addComponents(color),
new ActionRowBuilder<TextInputBuilder>().addComponents(image),
new ActionRowBuilder<TextInputBuilder>().addComponents(footer)
);
await interaction.showModal(modal);
}
public async handleModal(interaction: ModalSubmitInteraction) {
if (interaction.customId === 'embed:create') {
const draft: EmbedDraft = {
guildId: interaction.guildId || '',
title: interaction.fields.getTextInputValue('title') || undefined,
description: interaction.fields.getTextInputValue('description') || undefined,
color: this.parseColor(interaction.fields.getTextInputValue('color')),
imageUrl: interaction.fields.getTextInputValue('image') || undefined,
footer: interaction.fields.getTextInputValue('footer') || undefined
};
await interaction.reply({ ...this.renderPreview(draft), ephemeral: true });
const msg = await interaction.fetchReply();
this.drafts.set(msg.id, draft);
return;
}
if (interaction.customId.startsWith('embed:buttonmodal:')) {
const messageId = interaction.customId.split(':')[2];
const draft = this.drafts.get(messageId);
if (!draft) {
await interaction.reply({ content: 'Diese Vorschau ist abgelaufen. Starte `/embed create` erneut.', ephemeral: true });
return;
}
draft.buttonLabel = interaction.fields.getTextInputValue('label') || undefined;
draft.buttonUrl = interaction.fields.getTextInputValue('url') || undefined;
if (interaction.isFromMessage()) {
await interaction.update(this.renderPreview(draft));
}
return;
}
}
public async handleComponent(interaction: ComponentInteraction) {
const messageId = interaction.message.id;
const draft = this.drafts.get(messageId);
if (!draft) {
await interaction.reply({ content: 'Diese Vorschau ist abgelaufen. Starte `/embed create` erneut.', ephemeral: true });
return;
}
if (interaction.isChannelSelectMenu()) {
draft.channelId = interaction.values[0];
await interaction.update(this.renderPreview(draft));
return;
}
const action = interaction.customId.split(':')[1];
if (action === 'addbutton') {
const modal = new ModalBuilder().setCustomId(`embed:buttonmodal:${messageId}`).setTitle('Link-Button');
const label = new TextInputBuilder().setCustomId('label').setLabel('Button-Label').setStyle(TextInputStyle.Short).setRequired(true).setValue(draft.buttonLabel || '');
const url = new TextInputBuilder().setCustomId('url').setLabel('Button-URL').setStyle(TextInputStyle.Short).setRequired(true).setValue(draft.buttonUrl || '');
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(label), new ActionRowBuilder<TextInputBuilder>().addComponents(url));
await interaction.showModal(modal);
return;
}
if (action === 'cancel') {
this.drafts.delete(messageId);
await interaction.update({ embeds: [new EmbedBuilder().setTitle('Abgebrochen').setColor(0x6b7280)], components: [] });
return;
}
if (action === 'send') {
if (!draft.channelId) {
await interaction.reply({ content: 'Bitte zuerst einen Zielkanal wählen.', ephemeral: true });
return;
}
const guild = interaction.guild;
const channel = guild ? await guild.channels.fetch(draft.channelId).catch(() => null) : null;
if (!channel || !channel.isTextBased()) {
await interaction.reply({ content: 'Kanal nicht gefunden.', ephemeral: true });
return;
}
const embed = this.buildEmbed(draft);
const components =
draft.buttonLabel && draft.buttonUrl
? [new ActionRowBuilder<ButtonBuilder>().addComponents(new ButtonBuilder().setLabel(draft.buttonLabel).setURL(draft.buttonUrl).setStyle(ButtonStyle.Link))]
: [];
await channel.send({ embeds: [embed], components });
this.drafts.delete(messageId);
await interaction.update({ content: `Embed gesendet in <#${draft.channelId}>.`, embeds: [], components: [] });
return;
}
}
private renderPreview(draft: EmbedDraft) {
const embed = this.buildEmbed(draft);
const channelRow = new ActionRowBuilder<ChannelSelectMenuBuilder>().addComponents(
new ChannelSelectMenuBuilder().setCustomId('embed:channel').setChannelTypes(ChannelType.GuildText).setPlaceholder('Zielkanal wählen')
);
const buttonRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId('embed:addbutton').setLabel(draft.buttonLabel ? 'Link-Button bearbeiten' : 'Link-Button hinzufügen').setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId('embed:send').setLabel('Senden').setStyle(ButtonStyle.Success).setDisabled(!draft.channelId),
new ButtonBuilder().setCustomId('embed:cancel').setLabel('Abbrechen').setStyle(ButtonStyle.Danger)
);
return { embeds: [embed], components: [channelRow, buttonRow] };
}
private buildEmbed(draft: EmbedDraft) {
const embed = new EmbedBuilder().setColor(draft.color ?? 0xf97316);
if (draft.title) embed.setTitle(draft.title);
if (draft.description) embed.setDescription(draft.description);
if (draft.imageUrl) embed.setImage(draft.imageUrl);
if (draft.footer) embed.setFooter({ text: draft.footer });
if (!draft.title && !draft.description) embed.setDescription('*Keine Inhalte gesetzt*');
return embed;
}
private parseColor(input: string): number | undefined {
if (!input) return undefined;
const hex = input.trim().replace(/^#/, '');
if (!/^[0-9a-fA-F]{6}$/.test(hex)) return undefined;
return parseInt(hex, 16);
}
}

View File

@@ -0,0 +1,91 @@
import { ChannelType, EmbedBuilder, Guild, TextChannel } from 'discord.js';
import { settingsStore } from '../config/state';
import { logger } from '../utils/logger';
interface ChannelLockSnapshot {
channelId: string;
sendMessages: boolean | null;
}
export class LockdownService {
public isActive(guildId: string): boolean {
return settingsStore.get(guildId)?.lockdownConfig?.active === true;
}
public async enable(guild: Guild, activatedBy: string, reason?: string, staffRoleId?: string): Promise<boolean> {
if (this.isActive(guild.id)) return false;
const everyone = guild.roles.everyone;
const textChannels = guild.channels.cache.filter(
(c): c is TextChannel => c.type === ChannelType.GuildText
);
const snapshot: ChannelLockSnapshot[] = [];
for (const channel of textChannels.values()) {
const overwrite = channel.permissionOverwrites.cache.get(everyone.id);
const sendMessages = overwrite?.deny.has('SendMessages')
? false
: overwrite?.allow.has('SendMessages')
? true
: null;
snapshot.push({ channelId: channel.id, sendMessages });
await channel.permissionOverwrites.edit(everyone, { SendMessages: false }).catch((err) => logger.error(`Lockdown: failed to lock ${channel.id}`, err));
}
await settingsStore.set(guild.id, {
lockdownConfig: {
active: true,
activatedAt: new Date().toISOString(),
activatedBy,
reason,
staffRoleId,
snapshot
}
});
await this.postLog(guild, 'Lockdown aktiviert', reason, staffRoleId, 0xdc2626, textChannels.size);
return true;
}
public async disable(guild: Guild): Promise<boolean> {
const cfg = settingsStore.get(guild.id);
const lockdown = cfg?.lockdownConfig;
if (!lockdown?.active) return false;
const everyone = guild.roles.everyone;
for (const entry of lockdown.snapshot || []) {
const channel = guild.channels.cache.get(entry.channelId);
if (!channel || channel.type !== ChannelType.GuildText) continue;
await (channel as TextChannel).permissionOverwrites
.edit(everyone, { SendMessages: entry.sendMessages })
.catch((err) => logger.error(`Lockdown: failed to unlock ${channel.id}`, err));
}
await settingsStore.set(guild.id, {
lockdownConfig: { active: false, snapshot: [] }
});
await this.postLog(guild, 'Lockdown aufgehoben', undefined, undefined, 0x22c55e);
return true;
}
private async postLog(guild: Guild, title: string, reason?: string, staffRoleId?: string, color = 0xdc2626, channelCount?: number) {
const cfg = settingsStore.get(guild.id);
const channelId = cfg?.loggingConfig?.logChannelId || cfg?.logChannelId;
if (!channelId) return;
const channel = await guild.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isTextBased()) return;
const embed = new EmbedBuilder()
.setTitle(`Raid-Schutz: ${title}`)
.setColor(color)
.setTimestamp()
.addFields(
{ name: 'Grund', value: reason || 'Kein Grund angegeben' },
...(channelCount !== undefined ? [{ name: 'Betroffene Kanäle', value: String(channelCount) }] : [])
);
const content = staffRoleId ? `<@&${staffRoleId}>` : undefined;
await channel.send({ content, embeds: [embed] }).catch((err) => logger.error('Failed to send lockdown log', err));
}
}

View File

@@ -12,7 +12,8 @@ export type ModuleKey =
| 'reactionRolesEnabled'
| 'eventsEnabled'
| 'registerEnabled'
| 'serverStatsEnabled';
| 'serverStatsEnabled'
| 'tasksEnabled';
export interface GuildModuleState {
key: ModuleKey;
@@ -33,7 +34,8 @@ const MODULES: Record<ModuleKey, { name: string; description: string }> = {
reactionRolesEnabled: { name: 'Reaction Roles', description: 'Reaktionen vergeben und entfernen Rollen.' },
eventsEnabled: { name: 'Termine', description: 'Events planen, erinnern und Anmeldungen sammeln.' },
registerEnabled: { name: 'Register', description: 'Registrierungsformulare und Bewerbungen.' },
serverStatsEnabled: { name: 'Server Stats', description: 'Zeigt Member-/Channel-Zahlen als Voice-Statistiken an.' }
serverStatsEnabled: { name: 'Server Stats', description: 'Zeigt Member-/Channel-Zahlen als Voice-Statistiken an.' },
tasksEnabled: { name: 'Team-Aufgaben', description: 'Aufgaben für das Team erstellen und Status per Buttons pflegen.' }
};
export class BotModuleService {

View File

@@ -0,0 +1,341 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonInteraction,
ButtonStyle,
ChannelSelectMenuBuilder,
ChannelSelectMenuInteraction,
ChannelType,
ChatInputCommandInteraction,
EmbedBuilder,
StringSelectMenuBuilder,
StringSelectMenuInteraction
} from 'discord.js';
import { settingsStore } from '../config/state';
import { BotModuleService, ModuleKey } from './moduleService';
import { TicketService } from './ticketService';
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ChannelSelectMenuInteraction;
type AutomodLevel = 'light' | 'normal' | 'strict';
interface WizardState {
guildId: string;
step: number;
serverType?: string;
availableModules: { key: ModuleKey; name: string; description: string }[];
selectedModules: Set<ModuleKey>;
ticketCategoryCreated?: boolean;
logChannelId?: string;
welcomeChannelId?: string;
automodLevel?: AutomodLevel;
}
const SERVER_TYPES: { key: string; label: string; presetModules: ModuleKey[] }[] = [
{ key: 'community', label: 'Community', presetModules: ['welcomeEnabled', 'levelingEnabled', 'birthdayEnabled', 'reactionRolesEnabled', 'eventsEnabled'] },
{ key: 'gaming', label: 'Gaming', presetModules: ['welcomeEnabled', 'levelingEnabled', 'musicEnabled', 'dynamicVoiceEnabled', 'eventsEnabled'] },
{ key: 'support', label: 'Support', presetModules: ['ticketsEnabled', 'statuspageEnabled', 'automodEnabled'] },
{ key: 'club', label: 'Club', presetModules: ['registerEnabled', 'ticketsEnabled', 'eventsEnabled', 'welcomeEnabled'] },
{ key: 'team', label: 'Team', presetModules: ['ticketsEnabled', 'automodEnabled', 'welcomeEnabled'] }
];
const STEP_COUNT = 7;
export class SetupWizardService {
private sessions = new Map<string, WizardState>();
constructor(private modules: BotModuleService, private tickets: TicketService) {}
public async start(interaction: ChatInputCommandInteraction) {
if (!interaction.guildId) return;
const modules = await this.modules.getModulesForGuild(interaction.guildId);
const state: WizardState = {
guildId: interaction.guildId,
step: 0,
availableModules: modules.map((m) => ({ key: m.key, name: m.name, description: m.description })),
selectedModules: new Set(modules.filter((m) => m.enabled).map((m) => m.key))
};
await interaction.reply({ ...this.render(state), ephemeral: true });
const msg = await interaction.fetchReply();
this.sessions.set(msg.id, state);
}
public async handleComponent(interaction: ComponentInteraction) {
const messageId = interaction.message.id;
const state = this.sessions.get(messageId);
if (!state) {
await interaction.reply({ content: 'Dieser Setup-Assistent ist abgelaufen. Starte `/setup` erneut.', ephemeral: true });
return;
}
const [, section, action] = interaction.customId.split(':');
if (section === 'cancel') {
this.sessions.delete(messageId);
await interaction.update({ embeds: [new EmbedBuilder().setTitle('Setup abgebrochen').setColor(0x6b7280)], components: [] });
return;
}
if (section === 'back') {
state.step = Math.max(0, state.step - 1);
await interaction.update(this.render(state));
return;
}
if (section === 'type' && interaction.isButton()) {
state.serverType = action;
const preset = SERVER_TYPES.find((t) => t.key === action);
preset?.presetModules.forEach((m) => state.selectedModules.add(m));
state.step = 1;
await interaction.update(this.render(state));
return;
}
if (section === 'modules') {
if (action === 'select' && interaction.isStringSelectMenu()) {
state.selectedModules = new Set(interaction.values as ModuleKey[]);
await interaction.update(this.render(state));
return;
}
if (action === 'next') {
state.step = 2;
await interaction.update(this.render(state));
return;
}
}
if (section === 'tickets') {
if (action === 'create' && interaction.guild) {
await this.tickets.ensureCategory(interaction.guild);
state.ticketCategoryCreated = true;
await interaction.update(this.render(state));
return;
}
if (action === 'next') {
state.step = 3;
await interaction.update(this.render(state));
return;
}
}
if (section === 'log') {
if (action === 'select' && interaction.isChannelSelectMenu()) {
state.logChannelId = interaction.values[0];
state.step = 4;
await interaction.update(this.render(state));
return;
}
if (action === 'skip') {
state.step = 4;
await interaction.update(this.render(state));
return;
}
}
if (section === 'welcome') {
if (action === 'select' && interaction.isChannelSelectMenu()) {
state.welcomeChannelId = interaction.values[0];
state.step = 5;
await interaction.update(this.render(state));
return;
}
if (action === 'skip') {
state.step = 5;
await interaction.update(this.render(state));
return;
}
}
if (section === 'automod' && interaction.isButton()) {
state.automodLevel = action as AutomodLevel;
state.step = 6;
await interaction.update(this.render(state));
return;
}
if (section === 'finish') {
await this.finish(state);
this.sessions.delete(messageId);
await interaction.update({
embeds: [new EmbedBuilder().setTitle('Setup abgeschlossen').setDescription('Papo wurde erfolgreich eingerichtet.').setColor(0xf97316)],
components: []
});
return;
}
}
private async finish(state: WizardState) {
for (const mod of state.availableModules) {
if (state.selectedModules.has(mod.key)) {
await this.modules.enableModule(state.guildId, mod.key);
} else {
await this.modules.disableModule(state.guildId, mod.key);
}
}
const patch: Record<string, any> = { automodEnabled: true, automodConfig: this.automodPreset(state.automodLevel ?? 'normal') };
if (state.logChannelId) patch.logChannelId = state.logChannelId;
if (state.welcomeChannelId) patch.welcomeConfig = { enabled: true, channelId: state.welcomeChannelId };
await settingsStore.set(state.guildId, patch);
}
private automodPreset(level: AutomodLevel) {
if (level === 'light') {
return {
linkFilter: false,
inviteFilter: true,
spamFilter: true,
badWordFilter: false,
capsFilter: false,
mentionSpamFilter: false,
filters: {
spamFilter: { action: 'delete' },
inviteFilter: { action: 'delete' }
},
strikeConfig: { enabled: false, thresholds: [] }
};
}
if (level === 'strict') {
return {
linkFilter: true,
inviteFilter: true,
spamFilter: true,
badWordFilter: true,
capsFilter: true,
mentionSpamFilter: true,
filters: {
badWordFilter: { action: 'timeout', timeoutMinutes: 10 },
capsFilter: { action: 'timeout', timeoutMinutes: 10 },
spamFilter: { action: 'timeout', timeoutMinutes: 10 },
mentionSpamFilter: { action: 'timeout', timeoutMinutes: 10 }
},
strikeConfig: {
enabled: true,
decayHours: 24,
thresholds: [
{ count: 3, action: 'timeout', timeoutMinutes: 10 },
{ count: 5, action: 'kick' }
]
}
};
}
return {
linkFilter: true,
inviteFilter: true,
spamFilter: true,
badWordFilter: true,
capsFilter: false,
mentionSpamFilter: true,
filters: {
spamFilter: { action: 'timeout', timeoutMinutes: 10 },
mentionSpamFilter: { action: 'timeout', timeoutMinutes: 10 }
},
strikeConfig: { enabled: false, thresholds: [] }
};
}
private render(state: WizardState): { embeds: EmbedBuilder[]; components: any[] } {
const embed = new EmbedBuilder().setColor(0xf97316).setFooter({ text: `Schritt ${state.step + 1} von ${STEP_COUNT}` });
const cancelRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId('setup:cancel').setLabel('Abbrechen').setStyle(ButtonStyle.Danger)
);
if (state.step > 0) {
cancelRow.addComponents(new ButtonBuilder().setCustomId('setup:back').setLabel('Zurück').setStyle(ButtonStyle.Secondary));
}
if (state.step === 0) {
embed.setTitle('Setup Server-Art').setDescription('Wähle die Art deines Servers, um passende Module vorzuschlagen.');
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
...SERVER_TYPES.map((t) =>
new ButtonBuilder()
.setCustomId(`setup:type:${t.key}`)
.setLabel(t.label)
.setStyle(state.serverType === t.key ? ButtonStyle.Primary : ButtonStyle.Secondary)
)
);
return { embeds: [embed], components: [row, cancelRow] };
}
if (state.step === 1) {
embed.setTitle('Setup Module').setDescription('Wähle, welche Module aktiv sein sollen.');
const select = new StringSelectMenuBuilder()
.setCustomId('setup:modules:select')
.setPlaceholder('Module auswählen')
.setMinValues(0)
.setMaxValues(state.availableModules.length)
.addOptions(
state.availableModules.map((m) => ({
label: m.name,
value: m.key,
description: m.description.slice(0, 100),
default: state.selectedModules.has(m.key)
}))
);
const selectRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select);
const nextRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId('setup:modules:next').setLabel('Weiter').setStyle(ButtonStyle.Primary)
);
return { embeds: [embed], components: [selectRow, nextRow, cancelRow] };
}
if (state.step === 2) {
embed
.setTitle('Setup Ticket-Kategorie')
.setDescription(
state.ticketCategoryCreated
? 'Die Kategorie "Tickets" wurde erstellt (bzw. existierte bereits).'
: 'Erstelle jetzt die Kategorie für neue Ticket-Kanäle, oder überspringe diesen Schritt.'
);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId('setup:tickets:create').setLabel('Jetzt erstellen').setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId('setup:tickets:next').setLabel('Weiter').setStyle(ButtonStyle.Primary)
);
return { embeds: [embed], components: [row, cancelRow] };
}
if (state.step === 3) {
embed.setTitle('Setup Log-Kanal').setDescription('Wähle den Kanal für Bot- und Automod-Logs.');
const select = new ChannelSelectMenuBuilder().setCustomId('setup:log:select').setChannelTypes(ChannelType.GuildText).setPlaceholder('Log-Kanal wählen');
const selectRow = new ActionRowBuilder<ChannelSelectMenuBuilder>().addComponents(select);
const skipRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId('setup:log:skip').setLabel('Überspringen').setStyle(ButtonStyle.Secondary)
);
return { embeds: [embed], components: [selectRow, skipRow, cancelRow] };
}
if (state.step === 4) {
embed.setTitle('Setup Willkommens-Kanal').setDescription('Wähle den Kanal für Willkommensnachrichten.');
const select = new ChannelSelectMenuBuilder().setCustomId('setup:welcome:select').setChannelTypes(ChannelType.GuildText).setPlaceholder('Welcome-Kanal wählen');
const selectRow = new ActionRowBuilder<ChannelSelectMenuBuilder>().addComponents(select);
const skipRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId('setup:welcome:skip').setLabel('Überspringen').setStyle(ButtonStyle.Secondary)
);
return { embeds: [embed], components: [selectRow, skipRow, cancelRow] };
}
if (state.step === 5) {
embed.setTitle('Setup Automod-Stärke').setDescription('Wie streng soll Automod eingreifen?');
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId('setup:automod:light').setLabel('Leicht').setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId('setup:automod:normal').setLabel('Normal').setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId('setup:automod:strict').setLabel('Streng').setStyle(ButtonStyle.Secondary)
);
return { embeds: [embed], components: [row, cancelRow] };
}
const moduleNames = state.availableModules.filter((m) => state.selectedModules.has(m.key)).map((m) => m.name);
embed
.setTitle('Setup Zusammenfassung')
.setDescription('Prüfe deine Auswahl und schließe die Einrichtung ab.')
.addFields(
{ name: 'Server-Art', value: SERVER_TYPES.find((t) => t.key === state.serverType)?.label || 'Nicht gewählt' },
{ name: 'Module', value: moduleNames.length ? moduleNames.join(', ') : 'Keine' },
{ name: 'Log-Kanal', value: state.logChannelId ? `<#${state.logChannelId}>` : 'Nicht gesetzt' },
{ name: 'Welcome-Kanal', value: state.welcomeChannelId ? `<#${state.welcomeChannelId}>` : 'Nicht gesetzt' },
{ name: 'Automod-Stärke', value: state.automodLevel ?? 'normal' }
);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId('setup:finish').setLabel('Fertigstellen').setStyle(ButtonStyle.Success)
);
return { embeds: [embed], components: [row, cancelRow] };
}
}

View File

@@ -0,0 +1,38 @@
import { prisma } from '../database';
export type TaskStatus = 'open' | 'in_progress' | 'done';
export class TaskService {
public async createTask(data: {
guildId: string;
title: string;
description?: string;
assigneeId?: string;
createdBy: string;
createdByTag: string;
}) {
return prisma.staffTask.create({ data });
}
public async listTasks(guildId: string, status?: string) {
return prisma.staffTask.findMany({
where: { guildId, ...(status ? { status } : {}) },
orderBy: { createdAt: 'desc' }
});
}
public async getTask(id: string) {
return prisma.staffTask.findUnique({ where: { id } });
}
public async updateStatus(id: string, status: TaskStatus) {
return prisma.staffTask.update({ where: { id }, data: { status } });
}
public async deleteTask(guildId: string, id: string) {
const task = await prisma.staffTask.findFirst({ where: { id, guildId } });
if (!task) return false;
await prisma.staffTask.delete({ where: { id } });
return true;
}
}

View File

@@ -394,7 +394,7 @@ export class TicketService {
};
}
private async ensureCategory(guild: Guild): Promise<CategoryChannelResolvable> {
public async ensureCategory(guild: Guild): Promise<CategoryChannelResolvable> {
let category = guild.channels.cache.find(
(c) => c.type === ChannelType.GuildCategory && c.name.toLowerCase().includes(this.categoryName.toLowerCase())
);

View File

@@ -856,6 +856,44 @@ router.delete('/automod/strikes', requireAuth, async (req, res) => {
res.json({ ok: true });
});
router.get('/tasks', requireAuth, async (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
const status = typeof req.query.status === 'string' ? req.query.status : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const tasks = await context.tasks.listTasks(guildId, status);
res.json({ tasks });
});
router.post('/tasks', requireAuth, async (req, res) => {
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
const title = typeof req.body.title === 'string' ? req.body.title.trim() : '';
if (!guildId || !title) return res.status(400).json({ error: 'guildId and title required' });
const author = req.session.user;
const task = await context.tasks.createTask({
guildId,
title,
description: typeof req.body.description === 'string' ? req.body.description : undefined,
createdBy: author.id,
createdByTag: author?.username || author?.global_name || author?.id || 'Unbekannt'
});
res.json({ task });
});
router.post('/tasks/:id/status', requireAuth, async (req, res) => {
const status = typeof req.body.status === 'string' ? req.body.status : undefined;
if (!status || !['open', 'in_progress', 'done'].includes(status)) return res.status(400).json({ error: 'invalid status' });
const task = await context.tasks.updateStatus(req.params.id, status as any);
res.json({ task });
});
router.delete('/tasks/: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 ok = await context.tasks.deleteTask(guildId, req.params.id);
if (!ok) return res.status(404).json({ error: 'not found' });
res.json({ ok: true });
});
router.post('/settings', requireAuth, async (req, res) => {
const current = req.body.guildId ? settingsStore.get(req.body.guildId) ?? {} : {};
const {