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 { SettingsPage } from './pages/Settings';
|
||||||
import { ModulesPage } from './pages/Modules';
|
import { ModulesPage } from './pages/Modules';
|
||||||
import { Events } from './pages/Events';
|
import { Events } from './pages/Events';
|
||||||
|
import { Tasks } from './pages/Tasks';
|
||||||
import { Admin } from './pages/Admin';
|
import { Admin } from './pages/Admin';
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
@@ -45,6 +46,7 @@ function AppContent() {
|
|||||||
case 'settings': return <SettingsPage />;
|
case 'settings': return <SettingsPage />;
|
||||||
case 'modules': return <ModulesPage />;
|
case 'modules': return <ModulesPage />;
|
||||||
case 'events': return <Events />;
|
case 'events': return <Events />;
|
||||||
|
case 'tasks': return <Tasks />;
|
||||||
case 'admin': return <Admin />;
|
case 'admin': return <Admin />;
|
||||||
default: return <Dashboard />;
|
default: return <Dashboard />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
} from '@heroui/react';
|
} from '@heroui/react';
|
||||||
import {
|
import {
|
||||||
LogOut, PanelLeftClose, PanelLeft, Activity, AudioLines, CalendarDays,
|
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
|
Shield, Sparkles, Tag, Ticket, Wrench
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useApp } from '../../context/AppContext';
|
import { useApp } from '../../context/AppContext';
|
||||||
@@ -39,6 +39,7 @@ const navGroups = [
|
|||||||
items: [
|
items: [
|
||||||
{ key: 'automod', label: 'Automod', icon: <Shield size={18} /> },
|
{ key: 'automod', label: 'Automod', icon: <Shield size={18} /> },
|
||||||
{ key: 'reactionroles', label: 'Reaction Roles', icon: <Tag 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,
|
AppConfig, User, Guild, NavKey, TicketRecord, StatusService,
|
||||||
EventItem, ReactionRoleSet, ModuleItem, LogEntry, SettingsState,
|
EventItem, ReactionRoleSet, ModuleItem, LogEntry, SettingsState,
|
||||||
SupportLoginConfig, SupportLoginStatus, RegisterForm, RegisterFormField,
|
SupportLoginConfig, SupportLoginStatus, RegisterForm, RegisterFormField,
|
||||||
RegisterApplication, MusicSession
|
RegisterApplication, MusicSession, StaffTask
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
const appConfig: AppConfig = (window as any).__PAPO__ || {};
|
const appConfig: AppConfig = (window as any).__PAPO__ || {};
|
||||||
@@ -44,6 +44,8 @@ type AppState = {
|
|||||||
appNotes: any[];
|
appNotes: any[];
|
||||||
appHistory: any[];
|
appHistory: any[];
|
||||||
noteDraft: string;
|
noteDraft: string;
|
||||||
|
tasks: StaffTask[];
|
||||||
|
taskDraft: { title: string; description: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
type AppContextType = AppState & {
|
type AppContextType = AppState & {
|
||||||
@@ -120,6 +122,10 @@ type AppContextType = AppState & {
|
|||||||
openAppDetail: (id: string) => Promise<void>;
|
openAppDetail: (id: string) => Promise<void>;
|
||||||
setNoteDraft: (v: string) => void;
|
setNoteDraft: (v: string) => void;
|
||||||
addAppNote: () => Promise<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);
|
const AppContext = createContext<AppContextType | null>(null);
|
||||||
@@ -175,6 +181,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
const [appNotes, setAppNotes] = useState<any[]>([]);
|
const [appNotes, setAppNotes] = useState<any[]>([]);
|
||||||
const [appHistory, setAppHistory] = useState<any[]>([]);
|
const [appHistory, setAppHistory] = useState<any[]>([]);
|
||||||
const [noteDraft, setNoteDraft] = useState('');
|
const [noteDraft, setNoteDraft] = useState('');
|
||||||
|
const [tasks, setTasks] = useState<StaffTask[]>([]);
|
||||||
|
const [taskDraft, setTaskDraft] = useState({ title: '', description: '' });
|
||||||
|
|
||||||
const setSection = useCallback((key: NavKey) => {
|
const setSection = useCallback((key: NavKey) => {
|
||||||
setSectionState(key);
|
setSectionState(key);
|
||||||
@@ -225,7 +233,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
const [guildInfoRes, overviewRes, activityRes, logsRes, settingsRes, modulesRes,
|
const [guildInfoRes, overviewRes, activityRes, logsRes, settingsRes, modulesRes,
|
||||||
birthdayRes, reactionRes, statusRes, statsRes, eventsRes, supportLoginRes,
|
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>(`/guild/info?guildId=${encodeURIComponent(guildId)}`),
|
||||||
apiFetch<any>(`/overview?guildId=${encodeURIComponent(guildId)}`),
|
apiFetch<any>(`/overview?guildId=${encodeURIComponent(guildId)}`),
|
||||||
apiFetch<any>(`/guild/activity?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>(`/events?guildId=${encodeURIComponent(guildId)}`),
|
||||||
apiFetch<any>(`/tickets/support-login?guildId=${encodeURIComponent(guildId)}`),
|
apiFetch<any>(`/tickets/support-login?guildId=${encodeURIComponent(guildId)}`),
|
||||||
apiFetch<any>(`/register/forms?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);
|
setGuildInfo(guildInfoRes.guild || null);
|
||||||
setOverview(overviewRes);
|
setOverview(overviewRes);
|
||||||
@@ -258,6 +267,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
setSupportLogin(supportLoginRes);
|
setSupportLogin(supportLoginRes);
|
||||||
setRegisterForms(registerFormsRes.forms || []);
|
setRegisterForms(registerFormsRes.forms || []);
|
||||||
setRegisterApps(registerAppsRes.applications || []);
|
setRegisterApps(registerAppsRes.applications || []);
|
||||||
|
setTasks(tasksRes.tasks || []);
|
||||||
setReactionDraft({ title: '', channelId: '', entries: '' });
|
setReactionDraft({ title: '', channelId: '', entries: '' });
|
||||||
await Promise.all([loadTicketData(guildId), loadAdminData()]);
|
await Promise.all([loadTicketData(guildId), loadAdminData()]);
|
||||||
setStatusMessage('');
|
setStatusMessage('');
|
||||||
@@ -267,12 +277,12 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
async function loadAdminData() {
|
async function loadAdminData() {
|
||||||
if (!user?.isAdmin) return;
|
if (!user?.isAdmin) return;
|
||||||
try {
|
try {
|
||||||
const [overviewRes, , logsRes] = await Promise.all([
|
const [overviewRes, activityRes, logsRes] = await Promise.all([
|
||||||
apiFetch<any>('/admin/overview'),
|
apiFetch<any>('/admin/overview'),
|
||||||
apiFetch<any>('/admin/activity'),
|
apiFetch<any>('/admin/activity'),
|
||||||
apiFetch<any>('/admin/logs')
|
apiFetch<any>('/admin/logs')
|
||||||
]);
|
]);
|
||||||
setAdmin({ overview: overviewRes, activity: null, logs: logsRes.logs || [] });
|
setAdmin({ overview: overviewRes.overview || {}, activity: activityRes.points || [], logs: logsRes.logs || [] });
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,6 +551,28 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
setAppNotes(notesRes.notes || []);
|
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(() => {
|
const handleLogout = useCallback(() => {
|
||||||
window.location.href = `${appConfig.baseAuth || '/auth'}/logout`;
|
window.location.href = `${appConfig.baseAuth || '/auth'}/logout`;
|
||||||
}, []);
|
}, []);
|
||||||
@@ -555,7 +587,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
formDraft, editingFormId, registerTab, statusServiceDraft, statsItemDraft,
|
formDraft, editingFormId, registerTab, statusServiceDraft, statsItemDraft,
|
||||||
ticketDetail, ticketMessages, kbEditDraft, automationEditDraft,
|
ticketDetail, ticketMessages, kbEditDraft, automationEditDraft,
|
||||||
automodStrikes, registerStatusFilter, registerFormFilter, selectedAppId,
|
automodStrikes, registerStatusFilter, registerFormFilter, selectedAppId,
|
||||||
appNotes, appHistory, noteDraft,
|
appNotes, appHistory, noteDraft, tasks, taskDraft,
|
||||||
setCurrentGuildId, setSection, setSettings, setBirthday, setSupportLogin,
|
setCurrentGuildId, setSection, setSettings, setBirthday, setSupportLogin,
|
||||||
setStatusDraft, setStatsDraft, setStatusMessage, loadGuildData,
|
setStatusDraft, setStatsDraft, setStatusMessage, loadGuildData,
|
||||||
saveSettingsPayload, saveBirthday, saveStatuspage, saveServerStats,
|
saveSettingsPayload, saveBirthday, saveStatuspage, saveServerStats,
|
||||||
@@ -570,6 +602,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
setAutomationEditDraft,
|
setAutomationEditDraft,
|
||||||
loadAutomodStrikes, resetAutomodStrike, setRegisterStatusFilter, setRegisterFormFilter,
|
loadAutomodStrikes, resetAutomodStrike, setRegisterStatusFilter, setRegisterFormFilter,
|
||||||
loadRegisterApps, openAppDetail, setNoteDraft, addAppNote,
|
loadRegisterApps, openAppDetail, setNoteDraft, addAppNote,
|
||||||
|
setTaskDraft, createTask, updateTaskStatus, deleteTask,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</AppContext.Provider>
|
</AppContext.Provider>
|
||||||
|
|||||||
@@ -1,43 +1,54 @@
|
|||||||
import { Card, CardContent, CardHeader, Chip } from '@heroui/react';
|
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 { useApp } from '../context/AppContext';
|
||||||
import { SectionCard } from '../components/shared/SectionCard';
|
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() {
|
export function Admin() {
|
||||||
const { user, admin } = useApp();
|
const { user, admin } = useApp();
|
||||||
|
|
||||||
if (!user?.isAdmin) return null;
|
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 (
|
return (
|
||||||
<SectionCard title="Admin" subtitle="Bot-weite Übersichten">
|
<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>
|
<Card>
|
||||||
<CardHeader className="px-5 pt-5 pb-0">
|
<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>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-3 p-5">
|
<CardContent className="p-5">
|
||||||
<div className="bg-surface-tertiary flex items-center justify-between rounded-xl px-4 py-3 text-sm">
|
{chartItems.length ? (
|
||||||
<div className="flex items-center gap-2">
|
<BarComparisonChart items={chartItems} />
|
||||||
<Server size={14} className="text-accent" />
|
) : (
|
||||||
<span className="text-muted">Guilds</span>
|
<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>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -45,15 +56,19 @@ export function Admin() {
|
|||||||
<CardHeader className="flex items-center justify-between px-5 pt-5 pb-0">
|
<CardHeader className="flex items-center justify-between px-5 pt-5 pb-0">
|
||||||
<h3 className="text-base font-semibold">Letzte Admin Logs</h3>
|
<h3 className="text-base font-semibold">Letzte Admin Logs</h3>
|
||||||
<Chip size="sm" variant="soft" color="warning">
|
<Chip size="sm" variant="soft" color="warning">
|
||||||
{(admin.logs || []).length} Einträge
|
{logs.length} Einträge
|
||||||
</Chip>
|
</Chip>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-2 p-5">
|
<CardContent className="flex max-h-96 flex-col gap-2 overflow-y-auto p-5">
|
||||||
{(admin.logs || []).length ? (admin.logs || []).slice(0, 20).map((log, i) => (
|
{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">
|
<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" />
|
<Terminal size={14} className="mt-0.5 text-muted shrink-0" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-muted">{log.message || '-'}</p>
|
<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>
|
<p className="text-xs text-muted mt-0.5">{formatDate(log.timestamp)}</p>
|
||||||
</div>
|
</div>
|
||||||
</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'
|
| 'settings'
|
||||||
| 'modules'
|
| 'modules'
|
||||||
| 'events'
|
| 'events'
|
||||||
|
| 'tasks'
|
||||||
| 'admin';
|
| 'admin';
|
||||||
|
|
||||||
export type TicketRecord = {
|
export type TicketRecord = {
|
||||||
@@ -141,6 +142,19 @@ export type RegisterApplication = {
|
|||||||
createdAt?: string;
|
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 = {
|
export type MusicSession = {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
nowPlaying?: { title: string; url: string } | null;
|
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' })}`;
|
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) {
|
export function guildIconUrl(guild?: Guild | null) {
|
||||||
if (!guild) return undefined;
|
if (!guild) return undefined;
|
||||||
if (guild.icon) return `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png`;
|
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',
|
registerConfig: 'registerConfig',
|
||||||
serverStatsEnabled: 'serverStatsEnabled',
|
serverStatsEnabled: 'serverStatsEnabled',
|
||||||
serverStatsConfig: 'serverStatsConfig',
|
serverStatsConfig: 'serverStatsConfig',
|
||||||
|
lockdownConfig: 'lockdownConfig',
|
||||||
|
tasksEnabled: 'tasksEnabled',
|
||||||
supportRoleId: 'supportRoleId',
|
supportRoleId: 'supportRoleId',
|
||||||
updatedAt: 'updatedAt',
|
updatedAt: 'updatedAt',
|
||||||
createdAt: 'createdAt'
|
createdAt: 'createdAt'
|
||||||
@@ -314,6 +316,19 @@ exports.Prisma.AutomodStrikeScalarFieldEnum = {
|
|||||||
createdAt: 'createdAt'
|
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 = {
|
exports.Prisma.SortOrder = {
|
||||||
asc: 'asc',
|
asc: 'asc',
|
||||||
desc: 'desc'
|
desc: 'desc'
|
||||||
@@ -361,7 +376,8 @@ exports.Prisma.ModelName = {
|
|||||||
RegisterApplication: 'RegisterApplication',
|
RegisterApplication: 'RegisterApplication',
|
||||||
RegisterApplicationAnswer: 'RegisterApplicationAnswer',
|
RegisterApplicationAnswer: 'RegisterApplicationAnswer',
|
||||||
RegisterApplicationNote: 'RegisterApplicationNote',
|
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',
|
registerConfig: 'registerConfig',
|
||||||
serverStatsEnabled: 'serverStatsEnabled',
|
serverStatsEnabled: 'serverStatsEnabled',
|
||||||
serverStatsConfig: 'serverStatsConfig',
|
serverStatsConfig: 'serverStatsConfig',
|
||||||
|
lockdownConfig: 'lockdownConfig',
|
||||||
|
tasksEnabled: 'tasksEnabled',
|
||||||
supportRoleId: 'supportRoleId',
|
supportRoleId: 'supportRoleId',
|
||||||
updatedAt: 'updatedAt',
|
updatedAt: 'updatedAt',
|
||||||
createdAt: 'createdAt'
|
createdAt: 'createdAt'
|
||||||
@@ -314,6 +316,19 @@ exports.Prisma.AutomodStrikeScalarFieldEnum = {
|
|||||||
createdAt: 'createdAt'
|
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 = {
|
exports.Prisma.SortOrder = {
|
||||||
asc: 'asc',
|
asc: 'asc',
|
||||||
desc: 'desc'
|
desc: 'desc'
|
||||||
@@ -361,7 +376,8 @@ exports.Prisma.ModelName = {
|
|||||||
RegisterApplication: 'RegisterApplication',
|
RegisterApplication: 'RegisterApplication',
|
||||||
RegisterApplicationAnswer: 'RegisterApplicationAnswer',
|
RegisterApplicationAnswer: 'RegisterApplicationAnswer',
|
||||||
RegisterApplicationNote: 'RegisterApplicationNote',
|
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 { KnowledgeBaseService } from '../services/knowledgeBaseService';
|
||||||
import { RegisterService } from '../services/registerService';
|
import { RegisterService } from '../services/registerService';
|
||||||
import { StatsService } from '../services/statsService';
|
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 logging = new LoggingService();
|
||||||
|
const moduleService = new BotModuleService();
|
||||||
|
const ticketService = new TicketService();
|
||||||
|
|
||||||
export const context = {
|
export const context = {
|
||||||
client: null as Client | null,
|
client: null as Client | null,
|
||||||
@@ -25,10 +31,10 @@ export const context = {
|
|||||||
logging,
|
logging,
|
||||||
automod: new AutoModService(logging, true, true),
|
automod: new AutoModService(logging, true, true),
|
||||||
music: new MusicService(),
|
music: new MusicService(),
|
||||||
tickets: new TicketService(),
|
tickets: ticketService,
|
||||||
leveling: new LevelService(),
|
leveling: new LevelService(),
|
||||||
dynamicVoice: new DynamicVoiceService(),
|
dynamicVoice: new DynamicVoiceService(),
|
||||||
modules: new BotModuleService(),
|
modules: moduleService,
|
||||||
admin: new AdminService(),
|
admin: new AdminService(),
|
||||||
statuspage: new StatuspageService(),
|
statuspage: new StatuspageService(),
|
||||||
birthdays: new BirthdayService(),
|
birthdays: new BirthdayService(),
|
||||||
@@ -37,7 +43,11 @@ export const context = {
|
|||||||
ticketAutomation: new TicketAutomationService(),
|
ticketAutomation: new TicketAutomationService(),
|
||||||
knowledgeBase: new KnowledgeBaseService(),
|
knowledgeBase: new KnowledgeBaseService(),
|
||||||
register: new RegisterService(),
|
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({
|
context.modules.setHooks({
|
||||||
|
|||||||
@@ -77,6 +77,15 @@ export interface GuildSettings {
|
|||||||
};
|
};
|
||||||
supportRoleId?: string;
|
supportRoleId?: string;
|
||||||
welcomeEnabled?: boolean;
|
welcomeEnabled?: boolean;
|
||||||
|
tasksEnabled?: boolean;
|
||||||
|
lockdownConfig?: {
|
||||||
|
active?: boolean;
|
||||||
|
activatedAt?: string;
|
||||||
|
activatedBy?: string;
|
||||||
|
reason?: string;
|
||||||
|
staffRoleId?: string;
|
||||||
|
snapshot?: { channelId: string; sendMessages: boolean | null }[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class SettingsStore {
|
class SettingsStore {
|
||||||
@@ -95,7 +104,8 @@ class SettingsStore {
|
|||||||
'birthdayEnabled',
|
'birthdayEnabled',
|
||||||
'reactionRolesEnabled',
|
'reactionRolesEnabled',
|
||||||
'eventsEnabled',
|
'eventsEnabled',
|
||||||
'registerEnabled'
|
'registerEnabled',
|
||||||
|
'tasksEnabled'
|
||||||
] as const;
|
] as const;
|
||||||
defaultOn.forEach((key) => {
|
defaultOn.forEach((key) => {
|
||||||
if (normalized[key] === undefined) normalized[key] = true;
|
if (normalized[key] === undefined) normalized[key] = true;
|
||||||
@@ -136,6 +146,8 @@ class SettingsStore {
|
|||||||
registerConfig: (row as any).registerConfig ?? undefined,
|
registerConfig: (row as any).registerConfig ?? undefined,
|
||||||
serverStatsEnabled: (row as any).serverStatsEnabled ?? undefined,
|
serverStatsEnabled: (row as any).serverStatsEnabled ?? undefined,
|
||||||
serverStatsConfig: (row as any).serverStatsConfig ?? undefined,
|
serverStatsConfig: (row as any).serverStatsConfig ?? undefined,
|
||||||
|
lockdownConfig: (row as any).lockdownConfig ?? undefined,
|
||||||
|
tasksEnabled: (row as any).tasksEnabled ?? undefined,
|
||||||
supportRoleId: row.supportRoleId ?? undefined
|
supportRoleId: row.supportRoleId ?? undefined
|
||||||
} satisfies GuildSettings;
|
} satisfies GuildSettings;
|
||||||
this.cache.set(row.guildId, this.applyModuleDefaults(cfg));
|
this.cache.set(row.guildId, this.applyModuleDefaults(cfg));
|
||||||
@@ -191,6 +203,9 @@ class SettingsStore {
|
|||||||
if (partial.reactionRolesConfig) {
|
if (partial.reactionRolesConfig) {
|
||||||
merged.reactionRolesConfig = { ...(merged.reactionRolesConfig ?? {}), ...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.automodConfig = { ...mergedAutomod, supportLoginConfig: merged.supportLoginConfig ?? mergedAutomod['supportLoginConfig'] };
|
||||||
merged.statuspageEnabled = mergedAutomod.statuspageEnabled;
|
merged.statuspageEnabled = mergedAutomod.statuspageEnabled;
|
||||||
merged.statuspageConfig = mergedAutomod.statuspageConfig;
|
merged.statuspageConfig = mergedAutomod.statuspageConfig;
|
||||||
@@ -220,6 +235,8 @@ class SettingsStore {
|
|||||||
registerConfig: merged.registerConfig ?? Prisma.JsonNull,
|
registerConfig: merged.registerConfig ?? Prisma.JsonNull,
|
||||||
serverStatsEnabled: (merged as any).serverStatsEnabled ?? null,
|
serverStatsEnabled: (merged as any).serverStatsEnabled ?? null,
|
||||||
serverStatsConfig: (merged as any).serverStatsConfig ?? null,
|
serverStatsConfig: (merged as any).serverStatsConfig ?? null,
|
||||||
|
lockdownConfig: merged.lockdownConfig ?? Prisma.JsonNull,
|
||||||
|
tasksEnabled: merged.tasksEnabled ?? null,
|
||||||
supportRoleId: merged.supportRoleId ?? null
|
supportRoleId: merged.supportRoleId ?? null
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
@@ -244,6 +261,8 @@ class SettingsStore {
|
|||||||
registerConfig: merged.registerConfig ?? Prisma.JsonNull,
|
registerConfig: merged.registerConfig ?? Prisma.JsonNull,
|
||||||
serverStatsEnabled: (merged as any).serverStatsEnabled ?? null,
|
serverStatsEnabled: (merged as any).serverStatsEnabled ?? null,
|
||||||
serverStatsConfig: (merged as any).serverStatsConfig ?? null,
|
serverStatsConfig: (merged as any).serverStatsConfig ?? null,
|
||||||
|
lockdownConfig: merged.lockdownConfig ?? Prisma.JsonNull,
|
||||||
|
tasksEnabled: merged.tasksEnabled ?? null,
|
||||||
supportRoleId: merged.supportRoleId ?? 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?
|
registerConfig Json?
|
||||||
serverStatsEnabled Boolean?
|
serverStatsEnabled Boolean?
|
||||||
serverStatsConfig Json?
|
serverStatsConfig Json?
|
||||||
|
lockdownConfig Json?
|
||||||
|
tasksEnabled Boolean?
|
||||||
supportRoleId String?
|
supportRoleId String?
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -242,3 +244,18 @@ model AutomodStrike {
|
|||||||
|
|
||||||
@@index([guildId, userId])
|
@@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 = {
|
const event: EventHandler = {
|
||||||
name: 'guildMemberAdd',
|
name: 'guildMemberAdd',
|
||||||
execute(member: GuildMember) {
|
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 guildConfig = settingsStore.get(member.guild.id);
|
||||||
const welcomeCfg = guildConfig?.welcomeConfig || guildConfig?.automodConfig?.welcomeConfig;
|
const welcomeCfg = guildConfig?.welcomeConfig || guildConfig?.automodConfig?.welcomeConfig;
|
||||||
if (welcomeCfg?.enabled && welcomeCfg.channelId) {
|
if (welcomeCfg?.enabled && welcomeCfg.channelId) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Interaction } from 'discord.js';
|
import { Interaction } from 'discord.js';
|
||||||
import { EventHandler } from '../utils/types';
|
import { EventHandler } from '../utils/types';
|
||||||
import { context } from '../config/context';
|
import { context } from '../config/context';
|
||||||
|
import { buildTaskCard } from '../commands/utility/task';
|
||||||
|
|
||||||
const event: EventHandler = {
|
const event: EventHandler = {
|
||||||
name: 'interactionCreate',
|
name: 'interactionCreate',
|
||||||
@@ -12,6 +13,23 @@ const event: EventHandler = {
|
|||||||
return;
|
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.isButton()) {
|
||||||
if (interaction.customId.startsWith('event:')) {
|
if (interaction.customId.startsWith('event:')) {
|
||||||
const [_, action, eventId] = interaction.customId.split(':');
|
const [_, action, eventId] = interaction.customId.split(':');
|
||||||
@@ -32,6 +50,10 @@ const event: EventHandler = {
|
|||||||
await context.register.handleModal(interaction as any);
|
await context.register.handleModal(interaction as any);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (interaction.customId.startsWith('embed:')) {
|
||||||
|
await context.embedBuilder.handleModal(interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ export class CommandHandler {
|
|||||||
birthday: 'birthdayEnabled',
|
birthday: 'birthdayEnabled',
|
||||||
// Events
|
// Events
|
||||||
event: 'eventsEnabled',
|
event: 'eventsEnabled',
|
||||||
events: 'eventsEnabled'
|
events: 'eventsEnabled',
|
||||||
|
// Tasks
|
||||||
|
task: 'tasksEnabled'
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(private client: Client, private admin?: AdminService, private statuspage?: StatuspageService) {}
|
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'
|
| 'reactionRolesEnabled'
|
||||||
| 'eventsEnabled'
|
| 'eventsEnabled'
|
||||||
| 'registerEnabled'
|
| 'registerEnabled'
|
||||||
| 'serverStatsEnabled';
|
| 'serverStatsEnabled'
|
||||||
|
| 'tasksEnabled';
|
||||||
|
|
||||||
export interface GuildModuleState {
|
export interface GuildModuleState {
|
||||||
key: ModuleKey;
|
key: ModuleKey;
|
||||||
@@ -33,7 +34,8 @@ const MODULES: Record<ModuleKey, { name: string; description: string }> = {
|
|||||||
reactionRolesEnabled: { name: 'Reaction Roles', description: 'Reaktionen vergeben und entfernen Rollen.' },
|
reactionRolesEnabled: { name: 'Reaction Roles', description: 'Reaktionen vergeben und entfernen Rollen.' },
|
||||||
eventsEnabled: { name: 'Termine', description: 'Events planen, erinnern und Anmeldungen sammeln.' },
|
eventsEnabled: { name: 'Termine', description: 'Events planen, erinnern und Anmeldungen sammeln.' },
|
||||||
registerEnabled: { name: 'Register', description: 'Registrierungsformulare und Bewerbungen.' },
|
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 {
|
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(
|
let category = guild.channels.cache.find(
|
||||||
(c) => c.type === ChannelType.GuildCategory && c.name.toLowerCase().includes(this.categoryName.toLowerCase())
|
(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 });
|
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) => {
|
router.post('/settings', requireAuth, async (req, res) => {
|
||||||
const current = req.body.guildId ? settingsStore.get(req.body.guildId) ?? {} : {};
|
const current = req.body.guildId ? settingsStore.get(req.body.guildId) ?? {} : {};
|
||||||
const {
|
const {
|
||||||
|
|||||||
Reference in New Issue
Block a user