add setup wizard, lockdown, team tasks, embed builder; fix admin dashboard
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:
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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} /> },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
80
frontend/src/pages/Tasks.tsx
Normal file
80
frontend/src/pages/Tasks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
24
node_modules/.prisma/client/edge.js
generated
vendored
File diff suppressed because one or more lines are too long
18
node_modules/.prisma/client/index-browser.js
generated
vendored
18
node_modules/.prisma/client/index-browser.js
generated
vendored
@@ -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
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
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
18
node_modules/.prisma/client/wasm.js
generated
vendored
@@ -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'
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
48
src/commands/admin/lockdown.ts
Normal file
48
src/commands/admin/lockdown.ts
Normal 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;
|
||||
17
src/commands/utility/embed.ts
Normal file
17
src/commands/utility/embed.ts
Normal 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;
|
||||
16
src/commands/utility/setup.ts
Normal file
16
src/commands/utility/setup.ts
Normal 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;
|
||||
66
src/commands/utility/task.ts
Normal file
66
src/commands/utility/task.ts
Normal 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;
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
166
src/services/embedBuilderService.ts
Normal file
166
src/services/embedBuilderService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
91
src/services/lockdownService.ts
Normal file
91
src/services/lockdownService.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
341
src/services/setupService.ts
Normal file
341
src/services/setupService.ts
Normal 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] };
|
||||
}
|
||||
}
|
||||
38
src/services/taskService.ts
Normal file
38
src/services/taskService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user