add badges, weekly plans, polls, suggestions, mod-case system, branding, partner system, alt-account detection, gallery, growth tracking
Some checks failed
Deploy Discord Bot / deploy (push) Failing after -1m12s
SonarQube / sonar (push) Successful in 4s

Two feature batches plus a deploy fix:

Moderation & engagement: persistent ModCase history (retrofits ban/kick/
mute/timeout/tempban to record cases), /warn, /watch watchlist with
automatic alerts on deletions/tickets/automod hits, /case file lookup,
achievement badges (/badges, /profile) with message/streak/ticket/
birthday/event/booster triggers, /weekplan RSVP boards, /poll with
anonymous mode and scheduled auto-close, /suggest with vote/comment/
decide flow.

Branding & growth: /branding (per-guild embed color/logo/footer/bot
name/theme, applied across logging/ticket/register/embed-builder
embeds and the dashboard sidebar/accent color at runtime), two new
/setup server-type presets (Roleplay, Creator), /rules generate
(template-based rule sets), /partner apply/list/config with invite
validation and showcase posting, alt-account suspicion scoring on
join (account age, avatar, name pattern, join bursts, ban-list
similarity), /gallery submit with voting and weekly-winner scheduler,
invite-attributed join/leave tracking with a new Wachstum dashboard
page, and an expanded Admin dashboard (guild list, feature-usage
counters).

Also fixes production: the Docker container never ran `prisma migrate
deploy`, so schema changes never reached the live database. The
compose command now applies pending migrations on every start.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 23:15:35 +02:00
parent 2ff54970e2
commit aa246cd7ea
69 changed files with 21111 additions and 66 deletions

View File

@@ -9,7 +9,7 @@ services:
working_dir: /usr/src/app working_dir: /usr/src/app
env_file: env_file:
- .env - .env
command: sh -c "npm run dev" command: sh -c "npx prisma migrate deploy --schema=src/database/schema.prisma && npm run dev"
ports: ports:
- "3000:3000" - "3000:3000"
depends_on: depends_on:

View File

@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { useApp } from './context/AppContext'; import { useApp } from './context/AppContext';
import { AppLayout } from './components/layout/AppLayout'; import { AppLayout } from './components/layout/AppLayout';
import { GuildSelect } from './pages/GuildSelect'; import { GuildSelect } from './pages/GuildSelect';
@@ -17,10 +18,27 @@ 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 { Tasks } from './pages/Tasks';
import { Watchlist } from './pages/Watchlist';
import { Admin } from './pages/Admin'; import { Admin } from './pages/Admin';
import { Branding } from './pages/Branding';
import { Growth } from './pages/Growth';
const THEME_COLORS: Record<string, string> = {
orange: '#f97316',
blue: '#3b82f6',
green: '#22c55e',
purple: '#a855f7',
red: '#ef4444'
};
function AppContent() { function AppContent() {
const { guilds, currentGuildId, section } = useApp(); const { guilds, currentGuildId, section, settings } = useApp();
useEffect(() => {
const branding = settings.brandingConfig || {};
const color = branding.embedColor || (branding.theme ? THEME_COLORS[branding.theme] : undefined);
if (color) document.documentElement.style.setProperty('--accent', color);
}, [settings.brandingConfig]);
if (!guilds.length) { if (!guilds.length) {
return <GuildSelect />; return <GuildSelect />;
@@ -47,6 +65,9 @@ function AppContent() {
case 'modules': return <ModulesPage />; case 'modules': return <ModulesPage />;
case 'events': return <Events />; case 'events': return <Events />;
case 'tasks': return <Tasks />; case 'tasks': return <Tasks />;
case 'watchlist': return <Watchlist />;
case 'branding': return <Branding />;
case 'growth': return <Growth />;
case 'admin': return <Admin />; case 'admin': return <Admin />;
default: return <Dashboard />; default: return <Dashboard />;
} }

View File

@@ -5,8 +5,8 @@ import {
} from '@heroui/react'; } from '@heroui/react';
import { import {
LogOut, PanelLeftClose, PanelLeft, Activity, AudioLines, CalendarDays, LogOut, PanelLeftClose, PanelLeft, Activity, AudioLines, CalendarDays,
ClipboardList, Home, LogIn, ListChecks, Music, Puzzle, RadioTower, Settings, ClipboardList, Eye, Home, LogIn, ListChecks, Music, Palette, Puzzle, RadioTower, Settings,
Shield, Sparkles, Tag, Ticket, Wrench Shield, Sparkles, Tag, Ticket, TrendingUp, Wrench
} from 'lucide-react'; } from 'lucide-react';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { AppAvatar } from '../shared/AppAvatar'; import { AppAvatar } from '../shared/AppAvatar';
@@ -40,6 +40,7 @@ const navGroups = [
{ 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} /> }, { key: 'tasks', label: 'Team-Aufgaben', icon: <ListChecks size={18} /> },
{ key: 'watchlist', label: 'Watchlist', icon: <Eye size={18} /> },
] ]
}, },
{ {
@@ -49,29 +50,39 @@ const navGroups = [
{ key: 'music', label: 'Musik', icon: <Music size={18} /> }, { key: 'music', label: 'Musik', icon: <Music size={18} /> },
{ key: 'statuspage', label: 'Statuspage', icon: <RadioTower size={18} /> }, { key: 'statuspage', label: 'Statuspage', icon: <RadioTower size={18} /> },
{ key: 'serverstats', label: 'Server Stats', icon: <Activity size={18} /> }, { key: 'serverstats', label: 'Server Stats', icon: <Activity size={18} /> },
{ key: 'growth', label: 'Wachstum', icon: <TrendingUp size={18} /> },
] ]
}, },
{ {
label: 'System', label: 'System',
items: [ items: [
{ key: 'modules', label: 'Module', icon: <Puzzle size={18} /> }, { key: 'modules', label: 'Module', icon: <Puzzle size={18} /> },
{ key: 'branding', label: 'Branding', icon: <Palette size={18} /> },
{ key: 'settings', label: 'Einstellungen', icon: <Settings size={18} /> }, { key: 'settings', label: 'Einstellungen', icon: <Settings size={18} /> },
] ]
} }
]; ];
export function Sidebar() { export function Sidebar() {
const { user, guilds, currentGuildId, section, setCurrentGuildId, setSection, handleLogout } = useApp(); const { user, guilds, currentGuildId, section, setCurrentGuildId, setSection, handleLogout, settings } = useApp();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const branding = settings.brandingConfig || {};
const botName = branding.botName || 'Papo';
return ( return (
<aside className={`bg-surface border-r border-border flex h-full flex-col transition-all duration-200 ${collapsed ? 'w-14' : 'w-48'}`}> <aside className={`bg-surface border-r border-border flex h-full flex-col transition-all duration-200 ${collapsed ? 'w-14' : 'w-48'}`}>
<div className={`flex items-center gap-2 px-2.5 pt-3 pb-2.5 ${collapsed ? 'flex-col' : 'justify-between'}`}> <div className={`flex items-center gap-2 px-2.5 pt-3 pb-2.5 ${collapsed ? 'flex-col' : 'justify-between'}`}>
<div className={`flex min-w-0 items-center gap-2 ${collapsed ? 'flex-col' : ''}`}> <div className={`flex min-w-0 items-center gap-2 ${collapsed ? 'flex-col' : ''}`}>
<div className="bg-accent text-accent-foreground flex size-8 shrink-0 items-center justify-center rounded-xl text-sm font-black">P</div> {branding.logoUrl ? (
<img src={branding.logoUrl} alt={botName} className="size-8 shrink-0 rounded-xl object-cover" />
) : (
<div className="bg-accent text-accent-foreground flex size-8 shrink-0 items-center justify-center rounded-xl text-sm font-black">
{botName.trim()[0]?.toUpperCase() || 'P'}
</div>
)}
{!collapsed && ( {!collapsed && (
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-bold leading-tight">Papo</div> <div className="truncate text-sm font-bold leading-tight">{botName}</div>
<div className="text-[9px] uppercase tracking-widest text-muted">Dashboard</div> <div className="text-[9px] uppercase tracking-widest text-muted">Dashboard</div>
</div> </div>
)} )}

View File

@@ -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, StaffTask RegisterApplication, MusicSession, StaffTask, WatchlistEntry, GrowthStats
} from '../types'; } from '../types';
const appConfig: AppConfig = (window as any).__PAPO__ || {}; const appConfig: AppConfig = (window as any).__PAPO__ || {};
@@ -46,6 +46,8 @@ type AppState = {
noteDraft: string; noteDraft: string;
tasks: StaffTask[]; tasks: StaffTask[];
taskDraft: { title: string; description: string }; taskDraft: { title: string; description: string };
watchlistEntries: WatchlistEntry[];
growthStats: GrowthStats | null;
}; };
type AppContextType = AppState & { type AppContextType = AppState & {
@@ -126,6 +128,9 @@ type AppContextType = AppState & {
createTask: () => Promise<void>; createTask: () => Promise<void>;
updateTaskStatus: (id: string, status: string) => Promise<void>; updateTaskStatus: (id: string, status: string) => Promise<void>;
deleteTask: (id: string) => Promise<void>; deleteTask: (id: string) => Promise<void>;
loadWatchlist: () => Promise<void>;
removeFromWatchlist: (userId: string) => Promise<void>;
loadGrowthStats: () => Promise<void>;
}; };
const AppContext = createContext<AppContextType | null>(null); const AppContext = createContext<AppContextType | null>(null);
@@ -183,6 +188,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
const [noteDraft, setNoteDraft] = useState(''); const [noteDraft, setNoteDraft] = useState('');
const [tasks, setTasks] = useState<StaffTask[]>([]); const [tasks, setTasks] = useState<StaffTask[]>([]);
const [taskDraft, setTaskDraft] = useState({ title: '', description: '' }); const [taskDraft, setTaskDraft] = useState({ title: '', description: '' });
const [watchlistEntries, setWatchlistEntries] = useState<WatchlistEntry[]>([]);
const [growthStats, setGrowthStats] = useState<GrowthStats | null>(null);
const setSection = useCallback((key: NavKey) => { const setSection = useCallback((key: NavKey) => {
setSectionState(key); setSectionState(key);
@@ -233,7 +240,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, tasksRes] = await Promise.all([ registerFormsRes, registerAppsRes, tasksRes, watchlistRes] = 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)}`),
@@ -248,7 +255,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
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)}`) apiFetch<any>(`/tasks?guildId=${encodeURIComponent(guildId)}`),
apiFetch<any>(`/watchlist?guildId=${encodeURIComponent(guildId)}`)
]); ]);
setGuildInfo(guildInfoRes.guild || null); setGuildInfo(guildInfoRes.guild || null);
setOverview(overviewRes); setOverview(overviewRes);
@@ -268,6 +276,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
setRegisterForms(registerFormsRes.forms || []); setRegisterForms(registerFormsRes.forms || []);
setRegisterApps(registerAppsRes.applications || []); setRegisterApps(registerAppsRes.applications || []);
setTasks(tasksRes.tasks || []); setTasks(tasksRes.tasks || []);
setWatchlistEntries(watchlistRes.entries || []);
setReactionDraft({ title: '', channelId: '', entries: '' }); setReactionDraft({ title: '', channelId: '', entries: '' });
await Promise.all([loadTicketData(guildId), loadAdminData()]); await Promise.all([loadTicketData(guildId), loadAdminData()]);
setStatusMessage(''); setStatusMessage('');
@@ -282,7 +291,13 @@ export function AppProvider({ children }: { children: ReactNode }) {
apiFetch<any>('/admin/activity'), apiFetch<any>('/admin/activity'),
apiFetch<any>('/admin/logs') apiFetch<any>('/admin/logs')
]); ]);
setAdmin({ overview: overviewRes.overview || {}, activity: activityRes.points || [], logs: logsRes.logs || [] }); setAdmin({
overview: overviewRes.overview || {},
activity: activityRes.points || [],
logs: logsRes.logs || [],
guildList: overviewRes.guildList || [],
usage: overviewRes.usage || {}
});
} catch {} } catch {}
} }
@@ -573,6 +588,24 @@ export function AppProvider({ children }: { children: ReactNode }) {
await loadGuildData(currentGuildId); await loadGuildData(currentGuildId);
} }
async function loadWatchlist() {
if (!currentGuildId) return;
const res = await apiFetch<any>(`/watchlist?guildId=${encodeURIComponent(currentGuildId)}`);
setWatchlistEntries(res.entries || []);
}
async function removeFromWatchlist(userId: string) {
await apiFetch(`/watchlist?guildId=${encodeURIComponent(currentGuildId)}&userId=${encodeURIComponent(userId)}`, { method: 'DELETE' });
setStatusMessage('Von der Watchlist entfernt');
await loadWatchlist();
}
async function loadGrowthStats() {
if (!currentGuildId) return;
const res = await apiFetch<any>(`/growth?guildId=${encodeURIComponent(currentGuildId)}`);
setGrowthStats(res.stats || null);
}
const handleLogout = useCallback(() => { const handleLogout = useCallback(() => {
window.location.href = `${appConfig.baseAuth || '/auth'}/logout`; window.location.href = `${appConfig.baseAuth || '/auth'}/logout`;
}, []); }, []);
@@ -587,7 +620,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, tasks, taskDraft, appNotes, appHistory, noteDraft, tasks, taskDraft, watchlistEntries, growthStats,
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,
@@ -603,6 +636,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
loadAutomodStrikes, resetAutomodStrike, setRegisterStatusFilter, setRegisterFormFilter, loadAutomodStrikes, resetAutomodStrike, setRegisterStatusFilter, setRegisterFormFilter,
loadRegisterApps, openAppDetail, setNoteDraft, addAppNote, loadRegisterApps, openAppDetail, setNoteDraft, addAppNote,
setTaskDraft, createTask, updateTaskStatus, deleteTask, setTaskDraft, createTask, updateTaskStatus, deleteTask,
loadWatchlist, removeFromWatchlist, loadGrowthStats,
}}> }}>
{children} {children}
</AppContext.Provider> </AppContext.Provider>

View File

@@ -1,10 +1,11 @@
import { Card, CardContent, CardHeader, Chip } from '@heroui/react'; import { Card, CardContent, CardHeader, Chip } from '@heroui/react';
import { Activity, Clock, Server, Terminal } from 'lucide-react'; import { Activity, Award, ClipboardList, Clock, Eye, Handshake, Server, Terminal, Users } 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 { StatCard } from '../components/shared/StatCard'; import { StatCard } from '../components/shared/StatCard';
import { AppAvatar } from '../components/shared/AppAvatar';
import { BarComparisonChart } from '../components/shared/BarComparisonChart'; import { BarComparisonChart } from '../components/shared/BarComparisonChart';
import { formatDate, formatDuration } from '../utils/formatters'; import { formatDate, formatDuration, guildIconUrl } from '../utils/formatters';
const LEVEL_COLORS: Record<string, 'accent' | 'warning' | 'danger'> = { const LEVEL_COLORS: Record<string, 'accent' | 'warning' | 'danger'> = {
INFO: 'accent', INFO: 'accent',
@@ -20,6 +21,8 @@ export function Admin() {
const overview = admin.overview || {}; const overview = admin.overview || {};
const activityPoints: { hour: string; count: number }[] = admin.activity || []; const activityPoints: { hour: string; count: number }[] = admin.activity || [];
const logs: { timestamp: number; level: string; message: string; guildId?: string; category?: string }[] = admin.logs || []; const logs: { timestamp: number; level: string; message: string; guildId?: string; category?: string }[] = admin.logs || [];
const guildList: { id: string; name: string; icon?: string; memberCount: number; boostCount: number }[] = admin.guildList || [];
const usage = admin.usage || {};
const chartItems = activityPoints.slice(-12).map((p) => ({ const chartItems = activityPoints.slice(-12).map((p) => ({
label: new Date(p.hour).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }), label: new Date(p.hour).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }),
@@ -35,6 +38,14 @@ export function Admin() {
<StatCard icon={<Terminal size={18} />} label="Log-Einträge" value={logs.length} /> <StatCard icon={<Terminal size={18} />} label="Log-Einträge" value={logs.length} />
</div> </div>
<h3 className="mb-3 mt-6 text-sm font-semibold text-muted">Feature-Nutzung</h3>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard icon={<Award size={18} />} label="Abzeichen vergeben" value={usage.badgesAwarded ?? 0} color="accent" />
<StatCard icon={<ClipboardList size={18} />} label="Offene Aufgaben" value={usage.openTasks ?? 0} color="warning" />
<StatCard icon={<Eye size={18} />} label="Aktive Watchlist" value={usage.activeWatchlist ?? 0} color="danger" />
<StatCard icon={<Handshake size={18} />} label="Offene Partner-Anfragen" value={usage.pendingPartners ?? 0} />
</div>
<div className="mt-5 grid gap-5 xl:grid-cols-2"> <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">
@@ -54,33 +65,61 @@ export function Admin() {
<Card> <Card>
<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">Server</h3>
<Chip size="sm" variant="soft" color="warning"> <Chip size="sm" variant="soft" color="accent">
{logs.length} Einträge {guildList.length} Guilds
</Chip> </Chip>
</CardHeader> </CardHeader>
<CardContent className="flex max-h-96 flex-col gap-2 overflow-y-auto p-5"> <CardContent className="flex max-h-96 flex-col gap-2 overflow-y-auto p-5">
{logs.length ? logs.slice(0, 30).map((log, i) => ( {guildList.length ? guildList.map((g) => (
<div key={i} className="bg-surface-tertiary flex items-start gap-3 rounded-xl px-4 py-3 text-sm"> <div key={g.id} className="bg-surface-tertiary flex items-center gap-3 rounded-xl px-4 py-3 text-sm">
<Terminal size={14} className="mt-0.5 text-muted shrink-0" /> <AppAvatar size="sm" src={guildIconUrl(g as any)} name={g.name} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="truncate font-medium">{g.name}</div>
<Chip size="sm" variant="soft" color={LEVEL_COLORS[log.level] || 'accent'}>{log.level}</Chip> <div className="flex items-center gap-3 text-xs text-muted">
{log.category && <span className="text-xs text-muted">{log.category}</span>} <span className="flex items-center gap-1"><Users size={12} /> {g.memberCount}</span>
{g.boostCount > 0 && <span>💎 {g.boostCount}</span>}
</div> </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>
</div> </div>
)) : ( )) : (
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted"> <div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
<Terminal size={20} /> <Server size={20} />
Keine Logs Keine Server
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<Card className="mt-5">
<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">
{logs.length} Einträge
</Chip>
</CardHeader>
<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 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>
)) : (
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
<Terminal size={20} />
Keine Logs
</div>
)}
</CardContent>
</Card>
</SectionCard> </SectionCard>
); );
} }

View File

@@ -0,0 +1,100 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Button, Separator, TextField, Label } from '@heroui/react';
import { Palette, Save } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { DiscordPreview } from '../components/shared/DiscordPreview';
const THEMES: { key: string; label: string; color: string }[] = [
{ key: 'orange', label: 'Orange', color: '#f97316' },
{ key: 'blue', label: 'Blau', color: '#3b82f6' },
{ key: 'green', label: 'Grün', color: '#22c55e' },
{ key: 'purple', label: 'Lila', color: '#a855f7' },
{ key: 'red', label: 'Rot', color: '#ef4444' }
];
export function Branding() {
const { settings, setSettings, saveSettingsPayload } = useApp();
const branding = settings.brandingConfig || {};
const patch = (patchValue: Record<string, any>) =>
setSettings((s) => ({ ...s, brandingConfig: { ...(s.brandingConfig || {}), ...patchValue } }));
const previewColor = branding.embedColor || THEMES.find((t) => t.key === branding.theme)?.color || '#f97316';
return (
<SectionCard title="Branding" subtitle="Passe Papo an das Erscheinungsbild deines Servers an.">
<div className="grid gap-5 xl:grid-cols-2">
<Card>
<CardHeader>
<div>
<CardTitle><Palette size={16} className="inline mr-1.5" /> Branding konfigurieren</CardTitle>
<CardDescription>Farbe, Logo, Footer und Bot-Name für dieses Dashboard und Bot-Embeds.</CardDescription>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<TextField>
<Label>Embed-Farbe (Hex)</Label>
<Input placeholder="#f97316" value={branding.embedColor || ''} onChange={(e) => patch({ embedColor: e.target.value })} />
</TextField>
<TextField>
<Label>Logo-URL</Label>
<Input placeholder="https://..." value={branding.logoUrl || ''} onChange={(e) => patch({ logoUrl: e.target.value })} />
</TextField>
<TextField>
<Label>Footer-Text</Label>
<Input value={branding.footerText || ''} onChange={(e) => patch({ footerText: e.target.value })} />
</TextField>
<TextField>
<Label>Bot-Name im Dashboard</Label>
<Input placeholder="Papo" value={branding.botName || ''} onChange={(e) => patch({ botName: e.target.value })} />
</TextField>
<div>
<Label>Theme</Label>
<div className="mt-2 flex flex-wrap gap-2">
{THEMES.map((t) => (
<button
key={t.key}
type="button"
onClick={() => patch({ theme: t.key })}
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-sm ${branding.theme === t.key ? 'border-accent bg-accent-soft' : 'border-border'}`}
>
<span className="size-3 rounded-full" style={{ backgroundColor: t.color }} />
{t.label}
</button>
))}
</div>
</div>
<Separator />
<Button size="lg" variant="primary" onPress={() => saveSettingsPayload({ brandingConfig: branding }, 'Branding gespeichert')}>
<Save size={16} /> Speichern
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<div>
<CardTitle>Live Vorschau</CardTitle>
<CardDescription>So sehen deine Embeds ungefähr aus.</CardDescription>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<DiscordPreview
botName={branding.botName || 'Papo'}
title="Beispiel-Embed"
description="So wirkt dein gewähltes Branding auf Nachrichten von Papo."
footer={branding.footerText}
accentColor={previewColor}
/>
</CardContent>
</Card>
</div>
</SectionCard>
);
}

View File

@@ -0,0 +1,76 @@
import { useEffect } from 'react';
import { Card, CardContent, CardHeader } from '@heroui/react';
import { TrendingUp, TrendingDown, Gem, Link2, Handshake } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { StatCard } from '../components/shared/StatCard';
import { BarComparisonChart } from '../components/shared/BarComparisonChart';
export function Growth() {
const { currentGuildId, growthStats, loadGrowthStats } = useApp();
useEffect(() => {
if (currentGuildId) loadGrowthStats();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentGuildId]);
const stats = growthStats;
const chartItems = (stats?.dailyJoins || []).map((d) => ({
label: new Date(d.day).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }),
value: d.count
}));
return (
<SectionCard title="Wachstum" subtitle="Joins, Leaves, Invites und Booster im Überblick.">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard icon={<TrendingUp size={18} />} label="Joins (7 Tage)" value={stats?.joins7 ?? 0} color="success" />
<StatCard icon={<TrendingDown size={18} />} label="Leaves (7 Tage)" value={stats?.leaves7 ?? 0} color="danger" />
<StatCard icon={<Gem size={18} />} label="Booster" value={stats?.boosts ?? 0} color="accent" />
<StatCard icon={<Handshake size={18} />} label="Joins über Partner (30T)" value={stats?.partnerJoins30 ?? 0} color="warning" />
</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">Joins (letzte 7 Tage)</h3>
</CardHeader>
<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">
<TrendingUp size={24} />
Noch keine Daten erfasst
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Bester Invite (30 Tage)</h3>
</CardHeader>
<CardContent className="flex flex-col gap-3 p-5">
{stats?.bestInvite ? (
<div className="bg-surface-tertiary flex items-center gap-3 rounded-xl px-4 py-3 text-sm">
<Link2 size={16} className="text-accent shrink-0" />
<div className="min-w-0">
<div className="font-medium">discord.gg/{stats.bestInvite.code}</div>
<div className="text-xs text-muted">{stats.bestInvite.uses} Beitritte</div>
</div>
</div>
) : (
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
<Link2 size={20} />
Noch keine Invite-Nutzung erfasst
</div>
)}
<p className="text-xs text-muted">
Joins (30 Tage): {stats?.joins30 ?? 0} · Leaves (30 Tage): {stats?.leaves30 ?? 0}
</p>
</CardContent>
</Card>
</div>
</SectionCard>
);
}

View File

@@ -0,0 +1,36 @@
import { Card, CardContent, Button } from '@heroui/react';
import { Eye, UserMinus } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { formatDate } from '../utils/formatters';
export function Watchlist() {
const { watchlistEntries, removeFromWatchlist } = useApp();
return (
<SectionCard title="Watchlist" subtitle="Beobachtete Nutzer. Papo meldet auffälliges Verhalten im Log-Kanal.">
<h3 className="mb-3 text-base font-semibold">Beobachtete Nutzer ({watchlistEntries.length})</h3>
<div className="space-y-3">
{watchlistEntries.length ? watchlistEntries.map((w) => (
<Card key={w.id}>
<CardContent className="flex items-center justify-between gap-3 p-4">
<div className="min-w-0">
<div className="font-semibold truncate">User-ID {w.userId}</div>
<div className="text-xs text-muted truncate">{w.reason || 'Kein Grund angegeben'}</div>
<div className="text-xs text-muted">hinzugefügt von {w.addedBy} · {formatDate(w.addedAt)}</div>
</div>
<Button size="sm" variant="danger-soft" onPress={() => removeFromWatchlist(w.userId)}>
<UserMinus size={14} /> Entfernen
</Button>
</CardContent>
</Card>
)) : (
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted">
<Eye size={24} />
Aktuell wird niemand beobachtet.
</div>
)}
</div>
</SectionCard>
);
}

View File

@@ -35,6 +35,9 @@ export type NavKey =
| 'modules' | 'modules'
| 'events' | 'events'
| 'tasks' | 'tasks'
| 'watchlist'
| 'branding'
| 'growth'
| 'admin'; | 'admin';
export type TicketRecord = { export type TicketRecord = {
@@ -155,6 +158,27 @@ export type StaffTask = {
updatedAt?: string; updatedAt?: string;
}; };
export type WatchlistEntry = {
id: string;
guildId: string;
userId: string;
reason?: string | null;
addedBy: string;
addedAt?: string;
active: boolean;
};
export type GrowthStats = {
joins7: number;
leaves7: number;
joins30: number;
leaves30: number;
bestInvite: { code: string; uses: number } | null;
partnerJoins30: number;
boosts: number;
dailyJoins: { day: string; count: number }[];
};
export type MusicSession = { export type MusicSession = {
guildId: string; guildId: string;
nowPlaying?: { title: string; url: string } | null; nowPlaying?: { title: string; url: string } | null;

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

File diff suppressed because one or more lines are too long

View File

@@ -147,6 +147,10 @@ exports.Prisma.GuildSettingsScalarFieldEnum = {
serverStatsConfig: 'serverStatsConfig', serverStatsConfig: 'serverStatsConfig',
lockdownConfig: 'lockdownConfig', lockdownConfig: 'lockdownConfig',
tasksEnabled: 'tasksEnabled', tasksEnabled: 'tasksEnabled',
badgesEnabled: 'badgesEnabled',
brandingConfig: 'brandingConfig',
partnerConfig: 'partnerConfig',
galleryConfig: 'galleryConfig',
supportRoleId: 'supportRoleId', supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt', updatedAt: 'updatedAt',
createdAt: 'createdAt' createdAt: 'createdAt'
@@ -329,6 +333,145 @@ exports.Prisma.StaffTaskScalarFieldEnum = {
updatedAt: 'updatedAt' updatedAt: 'updatedAt'
}; };
exports.Prisma.ModCaseScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
type: 'type',
reason: 'reason',
moderatorId: 'moderatorId',
moderatorTag: 'moderatorTag',
createdAt: 'createdAt'
};
exports.Prisma.WatchlistScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
reason: 'reason',
addedBy: 'addedBy',
addedAt: 'addedAt',
active: 'active'
};
exports.Prisma.UserBadgeScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
badgeKey: 'badgeKey',
awardedAt: 'awardedAt'
};
exports.Prisma.UserActivityScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
messageCount: 'messageCount',
activeDays: 'activeDays',
lastActiveDay: 'lastActiveDay'
};
exports.Prisma.WeeklyPlanScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
channelId: 'channelId',
messageId: 'messageId',
title: 'title',
entries: 'entries',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.WeeklyPlanRsvpScalarFieldEnum = {
id: 'id',
planId: 'planId',
entryId: 'entryId',
userId: 'userId',
createdAt: 'createdAt'
};
exports.Prisma.PollScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
channelId: 'channelId',
messageId: 'messageId',
question: 'question',
options: 'options',
anonymous: 'anonymous',
closesAt: 'closesAt',
closed: 'closed',
createdBy: 'createdBy',
createdAt: 'createdAt'
};
exports.Prisma.PollVoteScalarFieldEnum = {
id: 'id',
pollId: 'pollId',
userId: 'userId',
optionId: 'optionId',
createdAt: 'createdAt'
};
exports.Prisma.SuggestionScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
channelId: 'channelId',
messageId: 'messageId',
userId: 'userId',
userTag: 'userTag',
content: 'content',
status: 'status',
createdAt: 'createdAt'
};
exports.Prisma.SuggestionVoteScalarFieldEnum = {
id: 'id',
suggestionId: 'suggestionId',
userId: 'userId',
value: 'value'
};
exports.Prisma.PartnerRequestScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
userTag: 'userTag',
serverName: 'serverName',
inviteCode: 'inviteCode',
memberCount: 'memberCount',
description: 'description',
status: 'status',
reviewedBy: 'reviewedBy',
createdAt: 'createdAt'
};
exports.Prisma.GalleryPostScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
channelId: 'channelId',
messageId: 'messageId',
userId: 'userId',
userTag: 'userTag',
imageUrl: 'imageUrl',
caption: 'caption',
createdAt: 'createdAt'
};
exports.Prisma.GalleryVoteScalarFieldEnum = {
id: 'id',
postId: 'postId',
userId: 'userId'
};
exports.Prisma.GuildGrowthEventScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
type: 'type',
userId: 'userId',
inviteCode: 'inviteCode',
createdAt: 'createdAt'
};
exports.Prisma.SortOrder = { exports.Prisma.SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'
@@ -377,7 +520,21 @@ exports.Prisma.ModelName = {
RegisterApplicationAnswer: 'RegisterApplicationAnswer', RegisterApplicationAnswer: 'RegisterApplicationAnswer',
RegisterApplicationNote: 'RegisterApplicationNote', RegisterApplicationNote: 'RegisterApplicationNote',
AutomodStrike: 'AutomodStrike', AutomodStrike: 'AutomodStrike',
StaffTask: 'StaffTask' StaffTask: 'StaffTask',
ModCase: 'ModCase',
Watchlist: 'Watchlist',
UserBadge: 'UserBadge',
UserActivity: 'UserActivity',
WeeklyPlan: 'WeeklyPlan',
WeeklyPlanRsvp: 'WeeklyPlanRsvp',
Poll: 'Poll',
PollVote: 'PollVote',
Suggestion: 'Suggestion',
SuggestionVote: 'SuggestionVote',
PartnerRequest: 'PartnerRequest',
GalleryPost: 'GalleryPost',
GalleryVote: 'GalleryVote',
GuildGrowthEvent: 'GuildGrowthEvent'
}; };
/** /**

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because one or more lines are too long

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

@@ -147,6 +147,10 @@ exports.Prisma.GuildSettingsScalarFieldEnum = {
serverStatsConfig: 'serverStatsConfig', serverStatsConfig: 'serverStatsConfig',
lockdownConfig: 'lockdownConfig', lockdownConfig: 'lockdownConfig',
tasksEnabled: 'tasksEnabled', tasksEnabled: 'tasksEnabled',
badgesEnabled: 'badgesEnabled',
brandingConfig: 'brandingConfig',
partnerConfig: 'partnerConfig',
galleryConfig: 'galleryConfig',
supportRoleId: 'supportRoleId', supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt', updatedAt: 'updatedAt',
createdAt: 'createdAt' createdAt: 'createdAt'
@@ -329,6 +333,145 @@ exports.Prisma.StaffTaskScalarFieldEnum = {
updatedAt: 'updatedAt' updatedAt: 'updatedAt'
}; };
exports.Prisma.ModCaseScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
type: 'type',
reason: 'reason',
moderatorId: 'moderatorId',
moderatorTag: 'moderatorTag',
createdAt: 'createdAt'
};
exports.Prisma.WatchlistScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
reason: 'reason',
addedBy: 'addedBy',
addedAt: 'addedAt',
active: 'active'
};
exports.Prisma.UserBadgeScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
badgeKey: 'badgeKey',
awardedAt: 'awardedAt'
};
exports.Prisma.UserActivityScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
messageCount: 'messageCount',
activeDays: 'activeDays',
lastActiveDay: 'lastActiveDay'
};
exports.Prisma.WeeklyPlanScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
channelId: 'channelId',
messageId: 'messageId',
title: 'title',
entries: 'entries',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.WeeklyPlanRsvpScalarFieldEnum = {
id: 'id',
planId: 'planId',
entryId: 'entryId',
userId: 'userId',
createdAt: 'createdAt'
};
exports.Prisma.PollScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
channelId: 'channelId',
messageId: 'messageId',
question: 'question',
options: 'options',
anonymous: 'anonymous',
closesAt: 'closesAt',
closed: 'closed',
createdBy: 'createdBy',
createdAt: 'createdAt'
};
exports.Prisma.PollVoteScalarFieldEnum = {
id: 'id',
pollId: 'pollId',
userId: 'userId',
optionId: 'optionId',
createdAt: 'createdAt'
};
exports.Prisma.SuggestionScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
channelId: 'channelId',
messageId: 'messageId',
userId: 'userId',
userTag: 'userTag',
content: 'content',
status: 'status',
createdAt: 'createdAt'
};
exports.Prisma.SuggestionVoteScalarFieldEnum = {
id: 'id',
suggestionId: 'suggestionId',
userId: 'userId',
value: 'value'
};
exports.Prisma.PartnerRequestScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
userTag: 'userTag',
serverName: 'serverName',
inviteCode: 'inviteCode',
memberCount: 'memberCount',
description: 'description',
status: 'status',
reviewedBy: 'reviewedBy',
createdAt: 'createdAt'
};
exports.Prisma.GalleryPostScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
channelId: 'channelId',
messageId: 'messageId',
userId: 'userId',
userTag: 'userTag',
imageUrl: 'imageUrl',
caption: 'caption',
createdAt: 'createdAt'
};
exports.Prisma.GalleryVoteScalarFieldEnum = {
id: 'id',
postId: 'postId',
userId: 'userId'
};
exports.Prisma.GuildGrowthEventScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
type: 'type',
userId: 'userId',
inviteCode: 'inviteCode',
createdAt: 'createdAt'
};
exports.Prisma.SortOrder = { exports.Prisma.SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'
@@ -377,7 +520,21 @@ exports.Prisma.ModelName = {
RegisterApplicationAnswer: 'RegisterApplicationAnswer', RegisterApplicationAnswer: 'RegisterApplicationAnswer',
RegisterApplicationNote: 'RegisterApplicationNote', RegisterApplicationNote: 'RegisterApplicationNote',
AutomodStrike: 'AutomodStrike', AutomodStrike: 'AutomodStrike',
StaffTask: 'StaffTask' StaffTask: 'StaffTask',
ModCase: 'ModCase',
Watchlist: 'Watchlist',
UserBadge: 'UserBadge',
UserActivity: 'UserActivity',
WeeklyPlan: 'WeeklyPlan',
WeeklyPlanRsvp: 'WeeklyPlanRsvp',
Poll: 'Poll',
PollVote: 'PollVote',
Suggestion: 'Suggestion',
SuggestionVote: 'SuggestionVote',
PartnerRequest: 'PartnerRequest',
GalleryPost: 'GalleryPost',
GalleryVote: 'GalleryVote',
GuildGrowthEvent: 'GuildGrowthEvent'
}; };
/** /**

View File

@@ -24,6 +24,8 @@ const command: SlashCommand = {
await member.ban({ reason }).catch(() => null); await member.ban({ reason }).catch(() => null);
await interaction.reply({ content: `${user.tag} wurde gebannt. Grund: ${reason}` }); await interaction.reply({ content: `${user.tag} wurde gebannt. Grund: ${reason}` });
context.logging.logAction(user, 'Ban', reason, interaction.guild); context.logging.logAction(user, 'Ban', reason, interaction.guild);
context.modCases.recordCase(interaction.guild.id, user.id, 'ban', reason, interaction.user.id, interaction.user.tag);
context.watchlist.notifyIfWatched(interaction.guild, user.id, 'Gebannt', reason);
} }
}; };

View File

@@ -0,0 +1,76 @@
import { EmbedBuilder, SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const TYPE_LABELS: Record<string, string> = {
warn: 'Warnungen',
mute: 'Mutes',
timeout: 'Timeouts',
kick: 'Kicks',
ban: 'Bans',
tempban: 'Tempbans',
note: 'Notizen',
watchlist_add: 'Watchlist (hinzugefügt)',
watchlist_remove: 'Watchlist (entfernt)'
};
const command: SlashCommand = {
guildOnly: true,
data: new SlashCommandBuilder()
.setName('case')
.setDescription('Zeigt oder ergänzt die Mod-Akte eines Nutzers.')
.addSubcommand((sub) =>
sub
.setName('view')
.setDescription('Zeigt die Mod-Akte eines Nutzers.')
.addUserOption((opt) => opt.setName('user').setDescription('Nutzer').setRequired(true))
)
.addSubcommand((sub) =>
sub
.setName('note')
.setDescription('Fügt eine interne Notiz zur Akte hinzu.')
.addUserOption((opt) => opt.setName('user').setDescription('Nutzer').setRequired(true))
.addStringOption((opt) => opt.setName('body').setDescription('Notiz').setRequired(true))
)
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
async execute(interaction: ChatInputCommandInteraction) {
if (!interaction.guild) return;
const sub = interaction.options.getSubcommand();
const user = interaction.options.getUser('user', true);
if (sub === 'note') {
const body = interaction.options.getString('body', true);
await context.modCases.addNote(interaction.guild.id, user.id, interaction.user.id, interaction.user.tag, body);
await interaction.reply({ content: `Notiz zur Akte von ${user.tag} hinzugefügt.`, ephemeral: true });
return;
}
const data = await context.modCases.getCase(interaction.guild.id, user.id);
const watched = context.watchlist.isWatched(interaction.guild.id, user.id);
const embed = new EmbedBuilder()
.setTitle(`Mod-Akte: ${user.tag}`)
.setColor(watched ? 0xeab308 : 0x7289da)
.addFields(
...Object.entries(TYPE_LABELS).map(([type, label]) => ({ name: label, value: String(data.counts[type] || 0), inline: true })),
{ name: 'Tickets erstellt', value: String(data.ticketCount), inline: true },
{ name: 'Automod-Verstöße', value: String(data.automodStrikes), inline: true },
{ name: 'Beobachtungsliste', value: watched ? 'Ja' : 'Nein', inline: true }
);
const recent = data.cases.slice(0, 10);
if (recent.length) {
embed.addFields({
name: 'Letzte Einträge',
value: recent
.map((c) => `**${TYPE_LABELS[c.type] || c.type}** — ${c.reason || 'Kein Grund'} (${c.moderatorTag}, ${c.createdAt.toLocaleDateString('de-DE')})`)
.join('\n')
.slice(0, 1024)
});
}
await interaction.reply({ embeds: [embed], ephemeral: true });
}
};
export default command;

View File

@@ -22,6 +22,8 @@ const command: SlashCommand = {
await member.kick(reason); await member.kick(reason);
await interaction.reply({ content: `${user.tag} wurde gekickt.` }); await interaction.reply({ content: `${user.tag} wurde gekickt.` });
context.logging.logAction(user, 'Kick', reason, interaction.guild); context.logging.logAction(user, 'Kick', reason, interaction.guild);
context.modCases.recordCase(interaction.guild.id, user.id, 'kick', reason, interaction.user.id, interaction.user.tag);
context.watchlist.notifyIfWatched(interaction.guild, user.id, 'Gekickt', reason);
} }
}; };

View File

@@ -24,6 +24,8 @@ const command: SlashCommand = {
await member.timeout(minutes * 60 * 1000, reason).catch(() => null); await member.timeout(minutes * 60 * 1000, reason).catch(() => null);
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gemutet.` }); await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gemutet.` });
context.logging.logAction(user, 'Mute', reason, interaction.guild); context.logging.logAction(user, 'Mute', reason, interaction.guild);
context.modCases.recordCase(interaction.guild.id, user.id, 'mute', reason, interaction.user.id, interaction.user.tag);
context.watchlist.notifyIfWatched(interaction.guild, user.id, 'Gemutet', reason);
} }
}; };

View File

@@ -26,6 +26,8 @@ const command: SlashCommand = {
await member.ban({ reason: `${reason} | ${minutes} Minuten` }); await member.ban({ reason: `${reason} | ${minutes} Minuten` });
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gebannt.` }); await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gebannt.` });
context.logging.logAction(user, 'Tempban', reason, interaction.guild); context.logging.logAction(user, 'Tempban', reason, interaction.guild);
context.modCases.recordCase(interaction.guild.id, user.id, 'tempban', reason, interaction.user.id, interaction.user.tag);
context.watchlist.notifyIfWatched(interaction.guild, user.id, 'Temporär gebannt', reason);
setTimeout(async () => { setTimeout(async () => {
await interaction.guild?.members.unban(user.id, 'Tempban abgelaufen').catch(() => null); await interaction.guild?.members.unban(user.id, 'Tempban abgelaufen').catch(() => null);

View File

@@ -24,6 +24,8 @@ const command: SlashCommand = {
await member.timeout(minutes * 60 * 1000, reason).catch(() => null); await member.timeout(minutes * 60 * 1000, reason).catch(() => null);
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten in Timeout gesetzt.` }); await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten in Timeout gesetzt.` });
context.logging.logAction(user, 'Timeout', reason, interaction.guild); context.logging.logAction(user, 'Timeout', reason, interaction.guild);
context.modCases.recordCase(interaction.guild.id, user.id, 'timeout', reason, interaction.user.id, interaction.user.tag);
context.watchlist.notifyIfWatched(interaction.guild, user.id, 'Timeout erhalten', reason);
} }
}; };

View File

@@ -0,0 +1,27 @@
import { SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,
data: new SlashCommandBuilder()
.setName('warn')
.setDescription('Verwarnt einen Nutzer.')
.addUserOption((opt) => opt.setName('user').setDescription('Nutzer').setRequired(true))
.addStringOption((opt) => opt.setName('reason').setDescription('Grund').setRequired(true))
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
async execute(interaction: ChatInputCommandInteraction) {
if (!interaction.guild) return;
const user = interaction.options.getUser('user', true);
const reason = interaction.options.getString('reason', true);
await context.modCases.recordCase(interaction.guild.id, user.id, 'warn', reason, interaction.user.id, interaction.user.tag);
context.logging.logAction(user, 'Warn', reason, interaction.guild);
context.watchlist.notifyIfWatched(interaction.guild, user.id, 'Verwarnt', reason);
await user.send(`Du wurdest auf **${interaction.guild.name}** verwarnt. Grund: ${reason}`).catch(() => undefined);
await interaction.reply({ content: `${user.tag} wurde verwarnt. Grund: ${reason}` });
}
};
export default command;

View File

@@ -0,0 +1,61 @@
import { EmbedBuilder, SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,
data: new SlashCommandBuilder()
.setName('watch')
.setDescription('Verwaltet die Beobachtungsliste.')
.addSubcommand((sub) =>
sub
.setName('add')
.setDescription('Setzt einen Nutzer auf die Beobachtungsliste.')
.addUserOption((opt) => opt.setName('user').setDescription('Nutzer').setRequired(true))
.addStringOption((opt) => opt.setName('reason').setDescription('Grund'))
)
.addSubcommand((sub) =>
sub
.setName('remove')
.setDescription('Entfernt einen Nutzer von der Beobachtungsliste.')
.addUserOption((opt) => opt.setName('user').setDescription('Nutzer').setRequired(true))
)
.addSubcommand((sub) => sub.setName('list').setDescription('Zeigt die aktuelle Beobachtungsliste.'))
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild),
async execute(interaction: ChatInputCommandInteraction) {
if (!interaction.guild) return;
const sub = interaction.options.getSubcommand();
if (sub === 'add') {
const user = interaction.options.getUser('user', true);
const reason = interaction.options.getString('reason') ?? undefined;
await context.watchlist.add(interaction.guild.id, user.id, reason, interaction.user.id);
await context.modCases.recordCase(interaction.guild.id, user.id, 'watchlist_add', reason, interaction.user.id, interaction.user.tag);
await interaction.reply({ content: `${user.tag} wurde auf die Beobachtungsliste gesetzt.`, ephemeral: true });
return;
}
if (sub === 'remove') {
const user = interaction.options.getUser('user', true);
await context.watchlist.remove(interaction.guild.id, user.id);
await context.modCases.recordCase(interaction.guild.id, user.id, 'watchlist_remove', undefined, interaction.user.id, interaction.user.tag);
await interaction.reply({ content: `${user.tag} wurde von der Beobachtungsliste entfernt.`, ephemeral: true });
return;
}
if (sub === 'list') {
const entries = await context.watchlist.list(interaction.guild.id);
const embed = new EmbedBuilder()
.setTitle('Beobachtungsliste')
.setColor(0xeab308)
.setDescription(
entries.length
? entries.map((e) => `<@${e.userId}> — ${e.reason || 'Kein Grund'} (von <@${e.addedBy}>)`).join('\n')
: 'Aktuell wird niemand beobachtet.'
);
await interaction.reply({ embeds: [embed], ephemeral: true });
}
}
};
export default command;

View File

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

View File

@@ -0,0 +1,63 @@
import { EmbedBuilder, SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
import { BADGES, BadgeKey } from '../../services/badgeService';
const command: SlashCommand = {
guildOnly: true,
data: new SlashCommandBuilder()
.setName('badges')
.setDescription('Zeigt oder vergibt Abzeichen.')
.addSubcommand((sub) =>
sub
.setName('list')
.setDescription('Zeigt die Abzeichen eines Nutzers.')
.addUserOption((opt) => opt.setName('user').setDescription('Nutzer (Standard: du selbst)'))
)
.addSubcommand((sub) =>
sub
.setName('award')
.setDescription('Vergibt manuell ein Abzeichen (z.B. Team des Monats).')
.addUserOption((opt) => opt.setName('user').setDescription('Nutzer').setRequired(true))
.addStringOption((opt) =>
opt
.setName('badge')
.setDescription('Abzeichen')
.setRequired(true)
.addChoices(...Object.entries(BADGES).map(([key, meta]) => ({ name: meta.name, value: key })))
)
)
.setDefaultMemberPermissions(PermissionFlagsBits.SendMessages),
async execute(interaction: ChatInputCommandInteraction) {
if (!interaction.guildId) return;
const sub = interaction.options.getSubcommand();
if (sub === 'award') {
const member = interaction.member;
const isManager = typeof member?.permissions !== 'string' && member?.permissions.has(PermissionFlagsBits.ManageGuild);
if (!isManager) {
await interaction.reply({ content: 'Du benötigst die Berechtigung "Server verwalten".', ephemeral: true });
return;
}
const user = interaction.options.getUser('user', true);
const badgeKey = interaction.options.getString('badge', true) as BadgeKey;
await context.badges.award(interaction.guildId, user.id, badgeKey);
await interaction.reply({ content: `${BADGES[badgeKey].icon} ${BADGES[badgeKey].name} wurde an ${user.tag} vergeben.` });
return;
}
const user = interaction.options.getUser('user') ?? interaction.user;
const earned = await context.badges.getBadges(interaction.guildId, user.id);
const embed = new EmbedBuilder()
.setTitle(`Abzeichen: ${user.tag}`)
.setColor(0xf97316)
.setDescription(
earned.length
? earned.map((b) => `${BADGES[b.badgeKey as BadgeKey]?.icon ?? '🏅'} **${BADGES[b.badgeKey as BadgeKey]?.name ?? b.badgeKey}**`).join('\n')
: 'Noch keine Abzeichen erhalten.'
);
await interaction.reply({ embeds: [embed], ephemeral: true });
}
};
export default command;

View File

@@ -0,0 +1,71 @@
import {
ActionRowBuilder,
ChatInputCommandInteraction,
ModalBuilder,
ModalSubmitInteraction,
PermissionFlagsBits,
SlashCommandBuilder,
TextInputBuilder,
TextInputStyle
} from 'discord.js';
import { SlashCommand } from '../../utils/types';
import { settingsStore } from '../../config/state';
const THEMES = ['orange', 'blue', 'green', 'purple', 'red'];
const command: SlashCommand = {
guildOnly: true,
data: new SlashCommandBuilder()
.setName('branding')
.setDescription('Passt das Erscheinungsbild von Papo für diesen Server an.')
.addSubcommand((sub) => sub.setName('set').setDescription('Öffnet das Branding-Formular.'))
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild),
async execute(interaction: ChatInputCommandInteraction) {
if (!interaction.guildId) return;
const current = settingsStore.get(interaction.guildId)?.brandingConfig || {};
const modal = new ModalBuilder().setCustomId('branding:set').setTitle('Branding anpassen');
const color = new TextInputBuilder().setCustomId('color').setLabel('Embed-Farbe (Hex, z.B. #f97316)').setStyle(TextInputStyle.Short).setRequired(false).setValue(current.embedColor || '');
const logo = new TextInputBuilder().setCustomId('logo').setLabel('Logo-URL').setStyle(TextInputStyle.Short).setRequired(false).setValue(current.logoUrl || '');
const footer = new TextInputBuilder().setCustomId('footer').setLabel('Footer-Text').setStyle(TextInputStyle.Short).setRequired(false).setValue(current.footerText || '');
const botName = new TextInputBuilder().setCustomId('botname').setLabel('Bot-Name im Dashboard').setStyle(TextInputStyle.Short).setRequired(false).setValue(current.botName || '');
const theme = new TextInputBuilder()
.setCustomId('theme')
.setLabel(`Theme (${THEMES.join('/')})`)
.setStyle(TextInputStyle.Short)
.setRequired(false)
.setValue(current.theme || '');
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(color),
new ActionRowBuilder<TextInputBuilder>().addComponents(logo),
new ActionRowBuilder<TextInputBuilder>().addComponents(footer),
new ActionRowBuilder<TextInputBuilder>().addComponents(botName),
new ActionRowBuilder<TextInputBuilder>().addComponents(theme)
);
await interaction.showModal(modal);
}
};
export async function handleBrandingModal(interaction: ModalSubmitInteraction) {
if (interaction.customId !== 'branding:set' || !interaction.guildId) return;
const color = interaction.fields.getTextInputValue('color').trim();
const logo = interaction.fields.getTextInputValue('logo').trim();
const footer = interaction.fields.getTextInputValue('footer').trim();
const botName = interaction.fields.getTextInputValue('botname').trim();
const themeInput = interaction.fields.getTextInputValue('theme').trim().toLowerCase();
const theme = THEMES.includes(themeInput) ? (themeInput as any) : undefined;
await settingsStore.set(interaction.guildId, {
brandingConfig: {
embedColor: color || undefined,
logoUrl: logo || undefined,
footerText: footer || undefined,
botName: botName || undefined,
theme
}
});
await interaction.reply({ content: 'Branding wurde aktualisiert.', ephemeral: true });
}
export default command;

View File

@@ -0,0 +1,56 @@
import { ChannelType, ChatInputCommandInteraction, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
import { settingsStore } from '../../config/state';
const command: SlashCommand = {
guildOnly: true,
data: new SlashCommandBuilder()
.setName('gallery')
.setDescription('Screenshot-/Kunst-Galerie.')
.addSubcommand((sub) =>
sub
.setName('submit')
.setDescription('Reicht ein Bild für die Galerie ein.')
.addAttachmentOption((opt) => opt.setName('image').setDescription('Bild').setRequired(true))
.addStringOption((opt) => opt.setName('caption').setDescription('Beschreibung'))
)
.addSubcommand((sub) =>
sub
.setName('config')
.setDescription('Konfiguriert Galerie-Kanal und Künstler-Rolle.')
.addChannelOption((opt) => opt.setName('channel').setDescription('Galerie-Kanal').addChannelTypes(ChannelType.GuildText))
.addRoleOption((opt) => opt.setName('artist_role').setDescription('Rolle für Künstler'))
),
async execute(interaction: ChatInputCommandInteraction) {
if (!interaction.guildId) return;
const sub = interaction.options.getSubcommand();
if (sub === 'submit') {
const attachment = interaction.options.getAttachment('image', true);
const caption = interaction.options.getString('caption') ?? undefined;
await context.gallery.submit(interaction, attachment, caption);
return;
}
if (sub === 'config') {
if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) {
await interaction.reply({ content: 'Du benötigst die Berechtigung "Server verwalten".', ephemeral: true });
return;
}
const channel = interaction.options.getChannel('channel');
const role = interaction.options.getRole('artist_role');
const current = settingsStore.get(interaction.guildId)?.galleryConfig || {};
await settingsStore.set(interaction.guildId, {
galleryConfig: {
channelId: channel?.id ?? current.channelId,
artistRoleId: role?.id ?? current.artistRoleId,
lastWinnerAt: current.lastWinnerAt
}
});
await interaction.reply({ content: 'Galerie-Konfiguration aktualisiert.', ephemeral: true });
}
}
};
export default command;

View File

@@ -0,0 +1,63 @@
import { ChannelType, ChatInputCommandInteraction, EmbedBuilder, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
import { settingsStore } from '../../config/state';
const command: SlashCommand = {
guildOnly: true,
data: new SlashCommandBuilder()
.setName('partner')
.setDescription('Partner-System für Discord-Server.')
.addSubcommand((sub) => sub.setName('apply').setDescription('Reicht eine Partner-Bewerbung ein.'))
.addSubcommand((sub) => sub.setName('list').setDescription('Zeigt alle akzeptierten Partner.'))
.addSubcommand((sub) =>
sub
.setName('config')
.setDescription('Konfiguriert Review- und Showcase-Kanal.')
.addChannelOption((opt) => opt.setName('review_channel').setDescription('Kanal für Bewerbungen').addChannelTypes(ChannelType.GuildText))
.addChannelOption((opt) => opt.setName('showcase_channel').setDescription('Kanal für akzeptierte Partner').addChannelTypes(ChannelType.GuildText))
),
async execute(interaction: ChatInputCommandInteraction) {
if (!interaction.guildId) return;
const sub = interaction.options.getSubcommand();
if (sub === 'apply') {
await context.partners.openApplyModal(interaction);
return;
}
if (sub === 'list') {
const partners = await context.partners.list(interaction.guildId);
const embed = new EmbedBuilder()
.setTitle('Partnerliste')
.setColor(context.branding.getColor(interaction.guildId))
.setDescription(
partners.length
? partners.map((p) => `**${p.serverName}** — discord.gg/${p.inviteCode}`).join('\n')
: 'Noch keine Partner.'
);
context.branding.applyFooter(embed, interaction.guildId);
await interaction.reply({ embeds: [embed] });
return;
}
if (sub === 'config') {
if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) {
await interaction.reply({ content: 'Du benötigst die Berechtigung "Server verwalten".', ephemeral: true });
return;
}
const reviewChannel = interaction.options.getChannel('review_channel');
const showcaseChannel = interaction.options.getChannel('showcase_channel');
const current = settingsStore.get(interaction.guildId)?.partnerConfig || {};
await settingsStore.set(interaction.guildId, {
partnerConfig: {
reviewChannelId: reviewChannel?.id ?? current.reviewChannelId,
showcaseChannelId: showcaseChannel?.id ?? current.showcaseChannelId
}
});
await interaction.reply({ content: 'Partner-Konfiguration aktualisiert.', ephemeral: true });
}
}
};
export default command;

View File

@@ -0,0 +1,32 @@
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('poll')
.setDescription('Erstellt eine Umfrage mit Buttons.')
.addSubcommand((sub) =>
sub
.setName('create')
.setDescription('Startet eine neue Umfrage.')
.addStringOption((opt) => opt.setName('question').setDescription('Frage').setRequired(true))
.addStringOption((opt) => opt.setName('options').setDescription('2-5 Optionen, komma-getrennt').setRequired(true))
.addBooleanOption((opt) => opt.setName('anonymous').setDescription('Ergebnisse erst nach Ende zeigen'))
.addIntegerOption((opt) => opt.setName('duration_minutes').setDescription('Automatisch schließen nach X Minuten'))
)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),
async execute(interaction: ChatInputCommandInteraction) {
if (!interaction.guildId || !interaction.channelId) return;
const question = interaction.options.getString('question', true);
const optionsRaw = interaction.options.getString('options', true);
const anonymous = interaction.options.getBoolean('anonymous') ?? false;
const duration = interaction.options.getInteger('duration_minutes') ?? undefined;
const options = optionsRaw.split(',').map((s) => s.trim()).filter(Boolean);
await context.polls.create(interaction, interaction.channelId, question, options, anonymous, duration);
}
};
export default command;

View File

@@ -0,0 +1,41 @@
import { EmbedBuilder, SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
import { BADGES, BadgeKey } from '../../services/badgeService';
const command: SlashCommand = {
guildOnly: true,
data: new SlashCommandBuilder()
.setName('profile')
.setDescription('Zeigt dein Profil oder das eines anderen Nutzers.')
.addUserOption((opt) => opt.setName('user').setDescription('Nutzer (Standard: du selbst)')),
async execute(interaction: ChatInputCommandInteraction) {
if (!interaction.guildId) return;
const user = interaction.options.getUser('user') ?? interaction.user;
const [level, activity, badges] = await Promise.all([
context.leveling.getLevel(user.id, interaction.guildId),
context.badges.getActivity(interaction.guildId, user.id),
context.badges.getBadges(interaction.guildId, user.id)
]);
const embed = new EmbedBuilder()
.setTitle(`Profil: ${user.tag}`)
.setThumbnail(user.displayAvatarURL())
.setColor(0xf97316)
.addFields(
{ name: 'Level', value: String(level.level), inline: true },
{ name: 'XP', value: String(level.xp), inline: true },
{ name: 'Nachrichten', value: String(activity?.messageCount ?? 0), inline: true },
{ name: 'Aktive Tage', value: String(activity?.activeDays ?? 0), inline: true },
{
name: `Abzeichen (${badges.length})`,
value: badges.length ? badges.map((b) => BADGES[b.badgeKey as BadgeKey]?.icon ?? '🏅').join(' ') : 'Keine'
}
);
await interaction.reply({ embeds: [embed] });
}
};
export default command;

View File

@@ -0,0 +1,47 @@
import { ChatInputCommandInteraction, EmbedBuilder, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,
data: new SlashCommandBuilder()
.setName('rules')
.setDescription('Generiert automatisch ein Regelwerk.')
.addSubcommand((sub) =>
sub
.setName('generate')
.setDescription('Erstellt ein Regelwerk anhand ein paar Angaben.')
.addStringOption((opt) => opt.setName('server_art').setDescription('Art des Servers (z.B. Gaming, Community)').setRequired(true))
.addStringOption((opt) => opt.setName('sprache').setDescription('Hauptsprache des Servers').setRequired(true))
.addBooleanOption((opt) => opt.setName('werbung_erlaubt').setDescription('Ist Werbung erlaubt?').setRequired(true))
.addStringOption((opt) =>
opt
.setName('strenge')
.setDescription('Wie streng sollen Verstöße geahndet werden?')
.setRequired(true)
.addChoices({ name: 'Locker', value: 'locker' }, { name: 'Streng', value: 'streng' })
)
.addIntegerOption((opt) => opt.setName('mindestalter').setDescription('Mindestalter zur Teilnahme'))
)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild),
async execute(interaction: ChatInputCommandInteraction) {
const serverType = interaction.options.getString('server_art', true);
const language = interaction.options.getString('sprache', true);
const adsAllowed = interaction.options.getBoolean('werbung_erlaubt', true);
const strictness = interaction.options.getString('strenge', true) as 'locker' | 'streng';
const minAge = interaction.options.getInteger('mindestalter') ?? undefined;
const { title, description, rules } = context.rules.generate({ serverType, language, adsAllowed, strictness, minAge });
const embed = new EmbedBuilder()
.setTitle(title)
.setDescription(description)
.setColor(context.branding.getColor(interaction.guildId || ''))
.addFields(rules.map((rule, i) => ({ name: `${i + 1}.`, value: rule })));
context.branding.applyFooter(embed, interaction.guildId || '');
await interaction.reply({ embeds: [embed] });
}
};
export default command;

View File

@@ -0,0 +1,17 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,
data: new SlashCommandBuilder()
.setName('suggest')
.setDescription('Reicht einen Vorschlag für die Community ein.')
.addStringOption((opt) => opt.setName('content').setDescription('Dein Vorschlag').setRequired(true)),
async execute(interaction: ChatInputCommandInteraction) {
const content = interaction.options.getString('content', true);
await context.suggestions.create(interaction, content);
}
};
export default command;

View File

@@ -0,0 +1,23 @@
import { ChannelType, 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('weekplan')
.setDescription('Erstellt einen Wochenplan mit RSVP-Buttons.')
.addSubcommand((sub) =>
sub
.setName('create')
.setDescription('Erstellt einen neuen Wochenplan.')
.addChannelOption((opt) => opt.setName('channel').setDescription('Zielkanal').addChannelTypes(ChannelType.GuildText).setRequired(true))
)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild),
async execute(interaction: ChatInputCommandInteraction) {
const channel = interaction.options.getChannel('channel', true);
await context.weeklyPlan.openModal(interaction, channel.id);
}
};
export default command;

View File

@@ -20,10 +20,25 @@ import { LockdownService } from '../services/lockdownService';
import { TaskService } from '../services/taskService'; import { TaskService } from '../services/taskService';
import { SetupWizardService } from '../services/setupService'; import { SetupWizardService } from '../services/setupService';
import { EmbedBuilderService } from '../services/embedBuilderService'; import { EmbedBuilderService } from '../services/embedBuilderService';
import { ModCaseService } from '../services/modCaseService';
import { WatchlistService } from '../services/watchlistService';
import { BadgeService } from '../services/badgeService';
import { WeeklyPlanService } from '../services/weeklyPlanService';
import { PollService } from '../services/pollService';
import { SuggestionService } from '../services/suggestionService';
import { BrandingService } from '../services/brandingService';
import { RulesService } from '../services/rulesService';
import { PartnerService } from '../services/partnerService';
import { AltAccountService } from '../services/altAccountService';
import { GalleryService } from '../services/galleryService';
import { GrowthService } from '../services/growthService';
const logging = new LoggingService(); const logging = new LoggingService();
const moduleService = new BotModuleService(); const moduleService = new BotModuleService();
const ticketService = new TicketService(); const ticketService = new TicketService();
const birthdayService = new BirthdayService();
const eventService = new EventService();
const badgeService = new BadgeService();
export const context = { export const context = {
client: null as Client | null, client: null as Client | null,
@@ -37,9 +52,9 @@ export const context = {
modules: moduleService, modules: moduleService,
admin: new AdminService(), admin: new AdminService(),
statuspage: new StatuspageService(), statuspage: new StatuspageService(),
birthdays: new BirthdayService(), birthdays: birthdayService,
reactionRoles: new ReactionRoleService(), reactionRoles: new ReactionRoleService(),
events: new EventService(), events: eventService,
ticketAutomation: new TicketAutomationService(), ticketAutomation: new TicketAutomationService(),
knowledgeBase: new KnowledgeBaseService(), knowledgeBase: new KnowledgeBaseService(),
register: new RegisterService(), register: new RegisterService(),
@@ -47,9 +62,24 @@ export const context = {
lockdown: new LockdownService(), lockdown: new LockdownService(),
tasks: new TaskService(), tasks: new TaskService(),
setup: new SetupWizardService(moduleService, ticketService), setup: new SetupWizardService(moduleService, ticketService),
embedBuilder: new EmbedBuilderService() embedBuilder: new EmbedBuilderService(),
modCases: new ModCaseService(),
watchlist: new WatchlistService(logging),
badges: badgeService,
weeklyPlan: new WeeklyPlanService(),
polls: new PollService(),
suggestions: new SuggestionService(),
branding: new BrandingService(),
rules: new RulesService(),
partners: new PartnerService(),
altAccounts: new AltAccountService(),
gallery: new GalleryService(),
growth: new GrowthService()
}; };
birthdayService.setBadgeService(badgeService);
eventService.setBadgeService(badgeService);
context.modules.setHooks({ context.modules.setHooks({
musicEnabled: { musicEnabled: {
onDisable: async () => context.music.stopAll() onDisable: async () => context.music.stopAll()

View File

@@ -78,6 +78,7 @@ export interface GuildSettings {
supportRoleId?: string; supportRoleId?: string;
welcomeEnabled?: boolean; welcomeEnabled?: boolean;
tasksEnabled?: boolean; tasksEnabled?: boolean;
badgesEnabled?: boolean;
lockdownConfig?: { lockdownConfig?: {
active?: boolean; active?: boolean;
activatedAt?: string; activatedAt?: string;
@@ -86,6 +87,22 @@ export interface GuildSettings {
staffRoleId?: string; staffRoleId?: string;
snapshot?: { channelId: string; sendMessages: boolean | null }[]; snapshot?: { channelId: string; sendMessages: boolean | null }[];
}; };
brandingConfig?: {
embedColor?: string;
logoUrl?: string;
footerText?: string;
botName?: string;
theme?: 'orange' | 'blue' | 'green' | 'purple' | 'red';
};
partnerConfig?: {
reviewChannelId?: string;
showcaseChannelId?: string;
};
galleryConfig?: {
channelId?: string;
artistRoleId?: string;
lastWinnerAt?: string;
};
} }
class SettingsStore { class SettingsStore {
@@ -105,7 +122,8 @@ class SettingsStore {
'reactionRolesEnabled', 'reactionRolesEnabled',
'eventsEnabled', 'eventsEnabled',
'registerEnabled', 'registerEnabled',
'tasksEnabled' 'tasksEnabled',
'badgesEnabled'
] as const; ] as const;
defaultOn.forEach((key) => { defaultOn.forEach((key) => {
if (normalized[key] === undefined) normalized[key] = true; if (normalized[key] === undefined) normalized[key] = true;
@@ -148,6 +166,10 @@ class SettingsStore {
serverStatsConfig: (row as any).serverStatsConfig ?? undefined, serverStatsConfig: (row as any).serverStatsConfig ?? undefined,
lockdownConfig: (row as any).lockdownConfig ?? undefined, lockdownConfig: (row as any).lockdownConfig ?? undefined,
tasksEnabled: (row as any).tasksEnabled ?? undefined, tasksEnabled: (row as any).tasksEnabled ?? undefined,
badgesEnabled: (row as any).badgesEnabled ?? undefined,
brandingConfig: (row as any).brandingConfig ?? undefined,
partnerConfig: (row as any).partnerConfig ?? undefined,
galleryConfig: (row as any).galleryConfig ?? 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));
@@ -206,6 +228,15 @@ class SettingsStore {
if (partial.lockdownConfig) { if (partial.lockdownConfig) {
merged.lockdownConfig = { ...(merged.lockdownConfig ?? {}), ...partial.lockdownConfig }; merged.lockdownConfig = { ...(merged.lockdownConfig ?? {}), ...partial.lockdownConfig };
} }
if (partial.brandingConfig) {
merged.brandingConfig = { ...(merged.brandingConfig ?? {}), ...partial.brandingConfig };
}
if (partial.partnerConfig) {
merged.partnerConfig = { ...(merged.partnerConfig ?? {}), ...partial.partnerConfig };
}
if (partial.galleryConfig) {
merged.galleryConfig = { ...(merged.galleryConfig ?? {}), ...partial.galleryConfig };
}
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;
@@ -237,6 +268,10 @@ class SettingsStore {
serverStatsConfig: (merged as any).serverStatsConfig ?? null, serverStatsConfig: (merged as any).serverStatsConfig ?? null,
lockdownConfig: merged.lockdownConfig ?? Prisma.JsonNull, lockdownConfig: merged.lockdownConfig ?? Prisma.JsonNull,
tasksEnabled: merged.tasksEnabled ?? null, tasksEnabled: merged.tasksEnabled ?? null,
badgesEnabled: merged.badgesEnabled ?? null,
brandingConfig: merged.brandingConfig ?? Prisma.JsonNull,
partnerConfig: merged.partnerConfig ?? Prisma.JsonNull,
galleryConfig: merged.galleryConfig ?? Prisma.JsonNull,
supportRoleId: merged.supportRoleId ?? null supportRoleId: merged.supportRoleId ?? null
}, },
create: { create: {
@@ -263,6 +298,10 @@ class SettingsStore {
serverStatsConfig: (merged as any).serverStatsConfig ?? null, serverStatsConfig: (merged as any).serverStatsConfig ?? null,
lockdownConfig: merged.lockdownConfig ?? Prisma.JsonNull, lockdownConfig: merged.lockdownConfig ?? Prisma.JsonNull,
tasksEnabled: merged.tasksEnabled ?? null, tasksEnabled: merged.tasksEnabled ?? null,
badgesEnabled: merged.badgesEnabled ?? null,
brandingConfig: merged.brandingConfig ?? Prisma.JsonNull,
partnerConfig: merged.partnerConfig ?? Prisma.JsonNull,
galleryConfig: merged.galleryConfig ?? Prisma.JsonNull,
supportRoleId: merged.supportRoleId ?? null supportRoleId: merged.supportRoleId ?? null
} }
}); });

View File

@@ -0,0 +1,178 @@
-- AlterTable
ALTER TABLE "GuildSettings" ADD COLUMN "badgesEnabled" BOOLEAN;
-- CreateTable
CREATE TABLE "ModCase" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"reason" TEXT,
"moderatorId" TEXT NOT NULL,
"moderatorTag" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ModCase_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Watchlist" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"reason" TEXT,
"addedBy" TEXT NOT NULL,
"addedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"active" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "Watchlist_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserBadge" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"badgeKey" TEXT NOT NULL,
"awardedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserBadge_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserActivity" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"messageCount" INTEGER NOT NULL DEFAULT 0,
"activeDays" INTEGER NOT NULL DEFAULT 0,
"lastActiveDay" TEXT,
CONSTRAINT "UserActivity_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WeeklyPlan" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"messageId" TEXT,
"title" TEXT NOT NULL DEFAULT 'Wochenplan',
"entries" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WeeklyPlan_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WeeklyPlanRsvp" (
"id" TEXT NOT NULL,
"planId" TEXT NOT NULL,
"entryId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WeeklyPlanRsvp_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Poll" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"messageId" TEXT,
"question" TEXT NOT NULL,
"options" JSONB NOT NULL,
"anonymous" BOOLEAN NOT NULL DEFAULT false,
"closesAt" TIMESTAMP(3),
"closed" BOOLEAN NOT NULL DEFAULT false,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Poll_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PollVote" (
"id" TEXT NOT NULL,
"pollId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"optionId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PollVote_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Suggestion" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"messageId" TEXT,
"userId" TEXT NOT NULL,
"userTag" TEXT NOT NULL,
"content" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Suggestion_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SuggestionVote" (
"id" TEXT NOT NULL,
"suggestionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"value" INTEGER NOT NULL,
CONSTRAINT "SuggestionVote_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ModCase_guildId_userId_idx" ON "ModCase"("guildId", "userId");
-- CreateIndex
CREATE UNIQUE INDEX "Watchlist_guildId_userId_key" ON "Watchlist"("guildId", "userId");
-- CreateIndex
CREATE INDEX "Watchlist_guildId_active_idx" ON "Watchlist"("guildId", "active");
-- CreateIndex
CREATE UNIQUE INDEX "UserBadge_guildId_userId_badgeKey_key" ON "UserBadge"("guildId", "userId", "badgeKey");
-- CreateIndex
CREATE INDEX "UserBadge_guildId_userId_idx" ON "UserBadge"("guildId", "userId");
-- CreateIndex
CREATE UNIQUE INDEX "UserActivity_guildId_userId_key" ON "UserActivity"("guildId", "userId");
-- CreateIndex
CREATE INDEX "WeeklyPlan_guildId_idx" ON "WeeklyPlan"("guildId");
-- CreateIndex
CREATE UNIQUE INDEX "WeeklyPlanRsvp_planId_entryId_userId_key" ON "WeeklyPlanRsvp"("planId", "entryId", "userId");
-- CreateIndex
CREATE INDEX "WeeklyPlanRsvp_planId_idx" ON "WeeklyPlanRsvp"("planId");
-- CreateIndex
CREATE INDEX "Poll_guildId_idx" ON "Poll"("guildId");
-- CreateIndex
CREATE UNIQUE INDEX "PollVote_pollId_userId_key" ON "PollVote"("pollId", "userId");
-- CreateIndex
CREATE INDEX "PollVote_pollId_idx" ON "PollVote"("pollId");
-- CreateIndex
CREATE INDEX "Suggestion_guildId_status_idx" ON "Suggestion"("guildId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "SuggestionVote_suggestionId_userId_key" ON "SuggestionVote"("suggestionId", "userId");
-- AddForeignKey
ALTER TABLE "PollVote" ADD CONSTRAINT "PollVote_pollId_fkey" FOREIGN KEY ("pollId") REFERENCES "Poll"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SuggestionVote" ADD CONSTRAINT "SuggestionVote_suggestionId_fkey" FOREIGN KEY ("suggestionId") REFERENCES "Suggestion"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,72 @@
-- AlterTable
ALTER TABLE "GuildSettings" ADD COLUMN "brandingConfig" JSONB,
ADD COLUMN "partnerConfig" JSONB,
ADD COLUMN "galleryConfig" JSONB;
-- CreateTable
CREATE TABLE "PartnerRequest" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"userTag" TEXT NOT NULL,
"serverName" TEXT NOT NULL,
"inviteCode" TEXT NOT NULL,
"memberCount" INTEGER,
"description" TEXT,
"status" TEXT NOT NULL DEFAULT 'pending',
"reviewedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PartnerRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GalleryPost" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"messageId" TEXT,
"userId" TEXT NOT NULL,
"userTag" TEXT NOT NULL,
"imageUrl" TEXT NOT NULL,
"caption" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GalleryPost_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GalleryVote" (
"id" TEXT NOT NULL,
"postId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "GalleryVote_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GuildGrowthEvent" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"inviteCode" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GuildGrowthEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "PartnerRequest_guildId_status_idx" ON "PartnerRequest"("guildId", "status");
-- CreateIndex
CREATE INDEX "GalleryPost_guildId_createdAt_idx" ON "GalleryPost"("guildId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "GalleryVote_postId_userId_key" ON "GalleryVote"("postId", "userId");
-- CreateIndex
CREATE INDEX "GuildGrowthEvent_guildId_type_createdAt_idx" ON "GuildGrowthEvent"("guildId", "type", "createdAt");
-- AddForeignKey
ALTER TABLE "GalleryVote" ADD CONSTRAINT "GalleryVote_postId_fkey" FOREIGN KEY ("postId") REFERENCES "GalleryPost"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -32,6 +32,10 @@ model GuildSettings {
serverStatsConfig Json? serverStatsConfig Json?
lockdownConfig Json? lockdownConfig Json?
tasksEnabled Boolean? tasksEnabled Boolean?
badgesEnabled Boolean?
brandingConfig Json?
partnerConfig Json?
galleryConfig Json?
supportRoleId String? supportRoleId String?
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -259,3 +263,186 @@ model StaffTask {
@@index([guildId, status]) @@index([guildId, status])
} }
model ModCase {
id String @id @default(cuid())
guildId String
userId String
type String
reason String?
moderatorId String
moderatorTag String
createdAt DateTime @default(now())
@@index([guildId, userId])
}
model Watchlist {
id String @id @default(cuid())
guildId String
userId String
reason String?
addedBy String
addedAt DateTime @default(now())
active Boolean @default(true)
@@unique([guildId, userId])
@@index([guildId, active])
}
model UserBadge {
id String @id @default(cuid())
guildId String
userId String
badgeKey String
awardedAt DateTime @default(now())
@@unique([guildId, userId, badgeKey])
@@index([guildId, userId])
}
model UserActivity {
id String @id @default(cuid())
guildId String
userId String
messageCount Int @default(0)
activeDays Int @default(0)
lastActiveDay String?
@@unique([guildId, userId])
}
model WeeklyPlan {
id String @id @default(cuid())
guildId String
channelId String
messageId String?
title String @default("Wochenplan")
entries Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([guildId])
}
model WeeklyPlanRsvp {
id String @id @default(cuid())
planId String
entryId String
userId String
createdAt DateTime @default(now())
@@unique([planId, entryId, userId])
@@index([planId])
}
model Poll {
id String @id @default(cuid())
guildId String
channelId String
messageId String?
question String
options Json
anonymous Boolean @default(false)
closesAt DateTime?
closed Boolean @default(false)
createdBy String
createdAt DateTime @default(now())
votes PollVote[]
@@index([guildId])
}
model PollVote {
id String @id @default(cuid())
pollId String
userId String
optionId String
createdAt DateTime @default(now())
poll Poll @relation(fields: [pollId], references: [id], onDelete: Cascade)
@@unique([pollId, userId])
@@index([pollId])
}
model Suggestion {
id String @id @default(cuid())
guildId String
channelId String
messageId String?
userId String
userTag String
content String
status String @default("pending")
createdAt DateTime @default(now())
votes SuggestionVote[]
@@index([guildId, status])
}
model SuggestionVote {
id String @id @default(cuid())
suggestionId String
userId String
value Int
suggestion Suggestion @relation(fields: [suggestionId], references: [id], onDelete: Cascade)
@@unique([suggestionId, userId])
}
model PartnerRequest {
id String @id @default(cuid())
guildId String
userId String
userTag String
serverName String
inviteCode String
memberCount Int?
description String?
status String @default("pending")
reviewedBy String?
createdAt DateTime @default(now())
@@index([guildId, status])
}
model GalleryPost {
id String @id @default(cuid())
guildId String
channelId String
messageId String?
userId String
userTag String
imageUrl String
caption String?
createdAt DateTime @default(now())
votes GalleryVote[]
@@index([guildId, createdAt])
}
model GalleryVote {
id String @id @default(cuid())
postId String
userId String
post GalleryPost @relation(fields: [postId], references: [id], onDelete: Cascade)
@@unique([postId, userId])
}
model GuildGrowthEvent {
id String @id @default(cuid())
guildId String
type String
userId String
inviteCode String?
createdAt DateTime @default(now())
@@index([guildId, type, createdAt])
}

View File

@@ -10,6 +10,7 @@ const event: EventHandler = {
if (context.commandHandler) { if (context.commandHandler) {
await context.commandHandler.registerGuildCommands(guild.id); await context.commandHandler.registerGuildCommands(guild.id);
} }
context.growth.cacheInvites(guild).catch(() => undefined);
} }
}; };

View File

@@ -11,6 +11,15 @@ const event: EventHandler = {
context.logging.logMemberJoin(member); context.logging.logMemberJoin(member);
return; return;
} }
context.altAccounts.check(member).then(({ score, reasons }) => {
if (context.altAccounts.isSuspicious(score)) context.logging.logSuspiciousJoin(member, reasons);
}).catch(() => undefined);
context.growth.resolveUsedInvite(member.guild).then((inviteCode) => {
context.growth.recordJoin(member.guild.id, member.id, inviteCode);
}).catch(() => undefined);
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) {

View File

@@ -7,6 +7,7 @@ const event: EventHandler = {
execute(member: GuildMember) { execute(member: GuildMember) {
context.logging.logMemberLeave(member); context.logging.logMemberLeave(member);
context.stats.refreshGuild(member.guild.id).catch(() => undefined); context.stats.refreshGuild(member.guild.id).catch(() => undefined);
context.growth.recordLeave(member.guild.id, member.id).catch(() => undefined);
} }
}; };

View File

@@ -6,6 +6,9 @@ const event: EventHandler = {
name: 'guildMemberUpdate', name: 'guildMemberUpdate',
execute(oldMember: GuildMember | PartialGuildMember, newMember: GuildMember) { execute(oldMember: GuildMember | PartialGuildMember, newMember: GuildMember) {
if (!newMember.guild) return; if (!newMember.guild) return;
if (!oldMember.premiumSince && newMember.premiumSince) {
context.badges.award(newMember.guild.id, newMember.id, 'server_booster');
}
const before = new Set(oldMember.roles?.cache?.keys?.() || []); const before = new Set(oldMember.roles?.cache?.keys?.() || []);
const after = new Set(newMember.roles.cache.keys()); const after = new Set(newMember.roles.cache.keys());
const added = Array.from(after).filter((id) => !before.has(id)); const added = Array.from(after).filter((id) => !before.has(id));

View File

@@ -2,6 +2,7 @@ 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'; import { buildTaskCard } from '../commands/utility/task';
import { handleBrandingModal } from '../commands/utility/branding';
const event: EventHandler = { const event: EventHandler = {
name: 'interactionCreate', name: 'interactionCreate',
@@ -13,7 +14,7 @@ const event: EventHandler = {
return; return;
} }
if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isChannelSelectMenu()) { if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isChannelSelectMenu() || interaction.isRoleSelectMenu()) {
if (interaction.customId.startsWith('setup:')) { if (interaction.customId.startsWith('setup:')) {
await context.setup.handleComponent(interaction as any); await context.setup.handleComponent(interaction as any);
return; return;
@@ -28,6 +29,26 @@ const event: EventHandler = {
await interaction.update(buildTaskCard(task)); await interaction.update(buildTaskCard(task));
return; return;
} }
if (interaction.isButton() && interaction.customId.startsWith('weekplan:')) {
await context.weeklyPlan.handleComponent(interaction);
return;
}
if (interaction.isButton() && interaction.customId.startsWith('poll:')) {
await context.polls.handleComponent(interaction);
return;
}
if (interaction.isButton() && interaction.customId.startsWith('suggestion:')) {
await context.suggestions.handleComponent(interaction);
return;
}
if (interaction.isButton() && interaction.customId.startsWith('partner:')) {
await context.partners.handleComponent(interaction);
return;
}
if (interaction.isButton() && interaction.customId.startsWith('gallery:')) {
await context.gallery.handleComponent(interaction);
return;
}
} }
if (interaction.isButton()) { if (interaction.isButton()) {
@@ -54,6 +75,18 @@ const event: EventHandler = {
await context.embedBuilder.handleModal(interaction); await context.embedBuilder.handleModal(interaction);
return; return;
} }
if (interaction.customId.startsWith('weekplan:create:')) {
await context.weeklyPlan.handleModal(interaction);
return;
}
if (interaction.customId === 'branding:set') {
await handleBrandingModal(interaction);
return;
}
if (interaction.customId === 'partner:apply') {
await context.partners.handleModal(interaction);
return;
}
} }
} }
}; };

View File

@@ -10,6 +10,7 @@ const event: EventHandler = {
if (message.guildId) context.admin.trackEvent('message', message.guildId); if (message.guildId) context.admin.trackEvent('message', message.guildId);
if (cfg?.automodEnabled === true) context.automod.checkMessage(message, cfg); if (cfg?.automodEnabled === true) context.automod.checkMessage(message, cfg);
if (cfg?.levelingEnabled === true) context.leveling.handleMessage(message); if (cfg?.levelingEnabled === true) context.leveling.handleMessage(message);
if (cfg?.badgesEnabled !== false) context.badges.trackMessage(message);
// Ticket SLA + KB // Ticket SLA + KB
await context.tickets.trackFirstResponse(message); await context.tickets.trackFirstResponse(message);
await context.tickets.suggestKnowledgeBase(message); await context.tickets.suggestKnowledgeBase(message);

View File

@@ -6,6 +6,9 @@ const event: EventHandler = {
name: 'messageDelete', name: 'messageDelete',
execute(message: Message) { execute(message: Message) {
context.logging.logMessageDelete(message); context.logging.logMessageDelete(message);
if (message.guild && message.author) {
context.watchlist.notifyIfWatched(message.guild, message.author.id, 'Nachricht gelöscht', message.content || undefined);
}
} }
}; };

View File

@@ -42,6 +42,10 @@ const event: EventHandler = {
for (const [gid] of client.guilds.cache) { for (const [gid] of client.guilds.cache) {
context.stats.refreshGuild(gid).catch((err) => logger.warn(`stats refresh failed for ${gid}: ${err}`)); context.stats.refreshGuild(gid).catch((err) => logger.warn(`stats refresh failed for ${gid}: ${err}`));
} }
for (const [, guild] of client.guilds.cache) {
context.growth.cacheInvites(guild).catch(() => undefined);
}
context.gallery.startScheduler();
} catch (err) { } catch (err) {
logger.warn(`Ready handler failed: ${err}`); logger.warn(`Ready handler failed: ${err}`);
} }

View File

@@ -10,6 +10,7 @@ import { settingsStore } from './config/state';
async function bootstrap() { async function bootstrap() {
context.admin.setStartTime(Date.now()); context.admin.setStartTime(Date.now());
await settingsStore.init(); await settingsStore.init();
await context.watchlist.loadAll();
const client = new Client({ const client = new Client({
intents: [ intents: [
@@ -36,6 +37,8 @@ async function bootstrap() {
context.events.startScheduler(); context.events.startScheduler();
context.register.setClient(client); context.register.setClient(client);
context.stats.setClient(client); context.stats.setClient(client);
context.polls.setClient(client);
context.polls.startScheduler();
await context.reactionRoles.loadCache(); await context.reactionRoles.loadCache();
logger.setSink((entry) => context.admin.pushLog(entry)); logger.setSink((entry) => context.admin.pushLog(entry));
for (const gid of settingsStore.all().keys()) { for (const gid of settingsStore.all().keys()) {

View File

@@ -0,0 +1,70 @@
import { GuildMember } from 'discord.js';
const SUSPICION_THRESHOLD = 3;
const ACCOUNT_AGE_DAYS = 7;
const JOIN_BURST_WINDOW_MS = 60_000;
const JOIN_BURST_COUNT = 5;
const SUSPICIOUS_NAME_REGEX = /^[a-z]+\d{4,}$/i;
export class AltAccountService {
private recentJoins = new Map<string, number[]>();
public async check(member: GuildMember): Promise<{ score: number; reasons: string[] }> {
let score = 0;
const reasons: string[] = [];
const ageDays = (Date.now() - member.user.createdTimestamp) / (1000 * 60 * 60 * 24);
if (ageDays < ACCOUNT_AGE_DAYS) {
score += 2;
reasons.push('Account jünger als 7 Tage');
}
if (!member.user.avatar) {
score += 1;
reasons.push('Kein Profilbild gesetzt');
}
if (SUSPICIOUS_NAME_REGEX.test(member.user.username)) {
score += 1;
reasons.push('Name folgt einem generischen Muster (z.B. Standard-Name + Zahlenfolge)');
}
if (this.registerJoinAndCheckBurst(member.guild.id)) {
score += 2;
reasons.push('Ungewöhnlich viele Joins in kurzer Zeit');
}
const similarToBanned = await this.isSimilarToBannedUser(member);
if (similarToBanned) {
score += 2;
reasons.push('Name ähnelt einem gebannten Nutzer');
}
return { score, reasons };
}
public isSuspicious(score: number) {
return score >= SUSPICION_THRESHOLD;
}
private registerJoinAndCheckBurst(guildId: string): boolean {
const now = Date.now();
const joins = (this.recentJoins.get(guildId) || []).filter((ts) => now - ts < JOIN_BURST_WINDOW_MS);
joins.push(now);
this.recentJoins.set(guildId, joins);
return joins.length > JOIN_BURST_COUNT;
}
private async isSimilarToBannedUser(member: GuildMember): Promise<boolean> {
try {
const bans = await member.guild.bans.fetch();
const name = member.user.username.toLowerCase();
return bans.some((ban) => {
const bannedName = ban.user.username.toLowerCase();
return bannedName.length >= 4 && (name.includes(bannedName) || bannedName.includes(name));
});
} catch {
return false;
}
}
}

View File

@@ -3,6 +3,7 @@ import { logger } from '../utils/logger';
import { GuildSettings } from '../config/state'; import { GuildSettings } from '../config/state';
import { LoggingService } from './loggingService'; import { LoggingService } from './loggingService';
import { prisma } from '../database'; import { prisma } from '../database';
import { context } from '../config/context';
export type AutomodFilterKey = export type AutomodFilterKey =
| 'linkFilter' | 'linkFilter'
@@ -284,6 +285,7 @@ export class AutoModService {
await prisma.automodStrike.create({ await prisma.automodStrike.create({
data: { guildId: message.guildId, userId: message.author.id, filterKey, weight: 1, reason } data: { guildId: message.guildId, userId: message.author.id, filterKey, weight: 1, reason }
}); });
context.watchlist.notifyIfWatched(message.guild, message.author.id, 'Automod-Verstoß', reason);
const decayHours = strikeCfg.decayHours ?? 0; const decayHours = strikeCfg.decayHours ?? 0;
const since = decayHours > 0 ? new Date(Date.now() - decayHours * 60 * 60 * 1000) : undefined; const since = decayHours > 0 ? new Date(Date.now() - decayHours * 60 * 60 * 1000) : undefined;
const rows = await prisma.automodStrike.findMany({ const rows = await prisma.automodStrike.findMany({

View File

@@ -0,0 +1,70 @@
import { Message } from 'discord.js';
import { prisma } from '../database';
export type BadgeKey =
| 'active_7_days'
| 'messages_100'
| 'first_ticket'
| 'birthday_set'
| 'event_participant'
| 'server_booster'
| 'team_of_month';
export const BADGES: Record<BadgeKey, { name: string; description: string; icon: string }> = {
active_7_days: { name: '7 Tage aktiv', description: 'An 7 verschiedenen Tagen aktiv geschrieben.', icon: '🔥' },
messages_100: { name: '100 Nachrichten', description: '100 Nachrichten geschrieben.', icon: '💬' },
first_ticket: { name: 'Erstes Ticket', description: 'Das erste Support-Ticket erstellt.', icon: '🎫' },
birthday_set: { name: 'Geburtstag eingetragen', description: 'Den eigenen Geburtstag hinterlegt.', icon: '🎂' },
event_participant: { name: 'Event-Teilnehmer', description: 'An einem Event teilgenommen.', icon: '📅' },
server_booster: { name: 'Server-Booster', description: 'Den Server geboostet.', icon: '💎' },
team_of_month: { name: 'Team des Monats', description: 'Vom Team ausgezeichnet.', icon: '🏆' }
};
function today() {
return new Date().toISOString().slice(0, 10);
}
export class BadgeService {
public async trackMessage(message: Message) {
if (!message.guild || message.author.bot) return;
const guildId = message.guild.id;
const userId = message.author.id;
const day = today();
const activity = await prisma.userActivity.upsert({
where: { guildId_userId: { guildId, userId } },
update: { messageCount: { increment: 1 } },
create: { guildId, userId, messageCount: 1, activeDays: 1, lastActiveDay: day }
});
if (activity.lastActiveDay !== day) {
await prisma.userActivity.update({
where: { guildId_userId: { guildId, userId } },
data: { activeDays: { increment: 1 }, lastActiveDay: day }
});
}
const updated = await prisma.userActivity.findUnique({ where: { guildId_userId: { guildId, userId } } });
if (!updated) return;
if (updated.messageCount >= 100) await this.award(guildId, userId, 'messages_100');
if (updated.activeDays >= 7) await this.award(guildId, userId, 'active_7_days');
}
public async award(guildId: string, userId: string, badgeKey: BadgeKey) {
await prisma.userBadge
.create({ data: { guildId, userId, badgeKey } })
.catch(() => undefined); // unique constraint -> already awarded
}
public async getBadges(guildId: string, userId: string) {
return prisma.userBadge.findMany({ where: { guildId, userId }, orderBy: { awardedAt: 'asc' } });
}
public async hasBadge(guildId: string, userId: string, badgeKey: BadgeKey) {
return !!(await prisma.userBadge.findUnique({ where: { guildId_userId_badgeKey: { guildId, userId, badgeKey } } }));
}
public async getActivity(guildId: string, userId: string) {
return prisma.userActivity.findUnique({ where: { guildId_userId: { guildId, userId } } });
}
}

View File

@@ -2,6 +2,7 @@ import { Client, EmbedBuilder, TextChannel } from 'discord.js';
import { prisma } from '../database'; import { prisma } from '../database';
import { settingsStore } from '../config/state'; import { settingsStore } from '../config/state';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { BadgeService } from './badgeService';
export function normalizeBirthdayInput(raw: string) { export function normalizeBirthdayInput(raw: string) {
const input = (raw || '').trim(); const input = (raw || '').trim();
@@ -60,11 +61,16 @@ export class BirthdayService {
private client: Client | null = null; private client: Client | null = null;
private timer: NodeJS.Timeout | null = null; private timer: NodeJS.Timeout | null = null;
private lastSent = new Map<string, string>(); private lastSent = new Map<string, string>();
private badges: BadgeService | null = null;
public setClient(client: Client) { public setClient(client: Client) {
this.client = client; this.client = client;
} }
public setBadgeService(badges: BadgeService) {
this.badges = badges;
}
public startScheduler(intervalMs = 60 * 60 * 1000) { public startScheduler(intervalMs = 60 * 60 * 1000) {
if (this.timer) clearInterval(this.timer); if (this.timer) clearInterval(this.timer);
const interval = Math.max(10 * 60 * 1000, intervalMs); const interval = Math.max(10 * 60 * 1000, intervalMs);
@@ -82,6 +88,7 @@ export class BirthdayService {
update: { birthDate }, update: { birthDate },
create: { guildId, userId, birthDate } create: { guildId, userId, birthDate }
}); });
await this.badges?.award(guildId, userId, 'birthday_set');
} }
public async getBirthday(guildId: string, userId: string) { public async getBirthday(guildId: string, userId: string) {

View File

@@ -0,0 +1,21 @@
import { settingsStore } from '../config/state';
const DEFAULT_COLOR = 0xf97316;
export class BrandingService {
public getColor(guildId: string): number {
const hex = settingsStore.get(guildId)?.brandingConfig?.embedColor;
if (!hex) return DEFAULT_COLOR;
const parsed = parseInt(hex.replace(/^#/, ''), 16);
return Number.isNaN(parsed) ? DEFAULT_COLOR : parsed;
}
public getFooter(guildId: string): string | undefined {
return settingsStore.get(guildId)?.brandingConfig?.footerText || undefined;
}
public applyFooter(embed: { setFooter: (options: { text: string }) => unknown }, guildId: string) {
const footer = this.getFooter(guildId);
if (footer) embed.setFooter({ text: footer });
}
}

View File

@@ -36,7 +36,10 @@ export class CommandHandler {
event: 'eventsEnabled', event: 'eventsEnabled',
events: 'eventsEnabled', events: 'eventsEnabled',
// Tasks // Tasks
task: 'tasksEnabled' task: 'tasksEnabled',
// Badges
badges: 'badgesEnabled',
profile: 'badgesEnabled'
}; };
constructor(private client: Client, private admin?: AdminService, private statuspage?: StatuspageService) {} constructor(private client: Client, private admin?: AdminService, private statuspage?: StatuspageService) {}

View File

@@ -10,11 +10,14 @@ import {
EmbedBuilder, EmbedBuilder,
ModalBuilder, ModalBuilder,
ModalSubmitInteraction, ModalSubmitInteraction,
RoleSelectMenuBuilder,
RoleSelectMenuInteraction,
TextInputBuilder, TextInputBuilder,
TextInputStyle TextInputStyle
} from 'discord.js'; } from 'discord.js';
import { context } from '../config/context';
type ComponentInteraction = ButtonInteraction | ChannelSelectMenuInteraction; type ComponentInteraction = ButtonInteraction | ChannelSelectMenuInteraction | RoleSelectMenuInteraction;
interface EmbedDraft { interface EmbedDraft {
guildId: string; guildId: string;
@@ -26,6 +29,7 @@ interface EmbedDraft {
buttonLabel?: string; buttonLabel?: string;
buttonUrl?: string; buttonUrl?: string;
channelId?: string; channelId?: string;
pingRoleId?: string;
} }
export class EmbedBuilderService { export class EmbedBuilderService {
@@ -94,6 +98,12 @@ export class EmbedBuilderService {
return; return;
} }
if (interaction.isRoleSelectMenu()) {
draft.pingRoleId = interaction.values[0];
await interaction.update(this.renderPreview(draft));
return;
}
const action = interaction.customId.split(':')[1]; const action = interaction.customId.split(':')[1];
if (action === 'addbutton') { if (action === 'addbutton') {
@@ -127,7 +137,12 @@ export class EmbedBuilderService {
draft.buttonLabel && draft.buttonUrl draft.buttonLabel && draft.buttonUrl
? [new ActionRowBuilder<ButtonBuilder>().addComponents(new ButtonBuilder().setLabel(draft.buttonLabel).setURL(draft.buttonUrl).setStyle(ButtonStyle.Link))] ? [new ActionRowBuilder<ButtonBuilder>().addComponents(new ButtonBuilder().setLabel(draft.buttonLabel).setURL(draft.buttonUrl).setStyle(ButtonStyle.Link))]
: []; : [];
await channel.send({ embeds: [embed], components }); await channel.send({
content: draft.pingRoleId ? `<@&${draft.pingRoleId}>` : undefined,
embeds: [embed],
components,
allowedMentions: draft.pingRoleId ? { roles: [draft.pingRoleId] } : undefined
});
this.drafts.delete(messageId); this.drafts.delete(messageId);
await interaction.update({ content: `Embed gesendet in <#${draft.channelId}>.`, embeds: [], components: [] }); await interaction.update({ content: `Embed gesendet in <#${draft.channelId}>.`, embeds: [], components: [] });
return; return;
@@ -139,16 +154,19 @@ export class EmbedBuilderService {
const channelRow = new ActionRowBuilder<ChannelSelectMenuBuilder>().addComponents( const channelRow = new ActionRowBuilder<ChannelSelectMenuBuilder>().addComponents(
new ChannelSelectMenuBuilder().setCustomId('embed:channel').setChannelTypes(ChannelType.GuildText).setPlaceholder('Zielkanal wählen') new ChannelSelectMenuBuilder().setCustomId('embed:channel').setChannelTypes(ChannelType.GuildText).setPlaceholder('Zielkanal wählen')
); );
const roleRow = new ActionRowBuilder<RoleSelectMenuBuilder>().addComponents(
new RoleSelectMenuBuilder().setCustomId('embed:role').setPlaceholder('Rolle pingen (optional)')
);
const buttonRow = new ActionRowBuilder<ButtonBuilder>().addComponents( 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: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:send').setLabel('Senden').setStyle(ButtonStyle.Success).setDisabled(!draft.channelId),
new ButtonBuilder().setCustomId('embed:cancel').setLabel('Abbrechen').setStyle(ButtonStyle.Danger) new ButtonBuilder().setCustomId('embed:cancel').setLabel('Abbrechen').setStyle(ButtonStyle.Danger)
); );
return { embeds: [embed], components: [channelRow, buttonRow] }; return { embeds: [embed], components: [channelRow, roleRow, buttonRow] };
} }
private buildEmbed(draft: EmbedDraft) { private buildEmbed(draft: EmbedDraft) {
const embed = new EmbedBuilder().setColor(draft.color ?? 0xf97316); const embed = new EmbedBuilder().setColor(draft.color ?? context.branding.getColor(draft.guildId));
if (draft.title) embed.setTitle(draft.title); if (draft.title) embed.setTitle(draft.title);
if (draft.description) embed.setDescription(draft.description); if (draft.description) embed.setDescription(draft.description);
if (draft.imageUrl) embed.setImage(draft.imageUrl); if (draft.imageUrl) embed.setImage(draft.imageUrl);

View File

@@ -2,17 +2,23 @@ import { Client, EmbedBuilder, TextChannel, ActionRowBuilder, ButtonBuilder, But
import { prisma } from '../database'; import { prisma } from '../database';
import { settingsStore } from '../config/state'; import { settingsStore } from '../config/state';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { BadgeService } from './badgeService';
export type RepeatType = 'none' | 'daily' | 'weekly' | 'monthly'; export type RepeatType = 'none' | 'daily' | 'weekly' | 'monthly';
export class EventService { export class EventService {
private client: Client | null = null; private client: Client | null = null;
private timer: NodeJS.Timeout | null = null; private timer: NodeJS.Timeout | null = null;
private badges: BadgeService | null = null;
public setClient(client: Client) { public setClient(client: Client) {
this.client = client; this.client = client;
} }
public setBadgeService(badges: BadgeService) {
this.badges = badges;
}
public startScheduler(intervalMs = 60000) { public startScheduler(intervalMs = 60000) {
if (this.timer) clearInterval(this.timer); if (this.timer) clearInterval(this.timer);
const interval = Math.max(30000, intervalMs); const interval = Math.max(30000, intervalMs);
@@ -135,6 +141,7 @@ export class EventService {
update: { canceledAt: null }, update: { canceledAt: null },
create: { eventId: ev.id, guildId: interaction.guildId, userId: interaction.user.id } create: { eventId: ev.id, guildId: interaction.guildId, userId: interaction.user.id }
}); });
await this.badges?.award(interaction.guildId, interaction.user.id, 'event_participant');
await interaction.reply({ content: 'Du bist angemeldet.', ephemeral: true }); await interaction.reply({ content: 'Du bist angemeldet.', ephemeral: true });
} else { } else {
const existing = await prisma.eventSignup.findFirst({ where: { eventId: ev.id, userId: interaction.user.id, canceledAt: null } }); const existing = await prisma.eventSignup.findFirst({ where: { eventId: ev.id, userId: interaction.user.id, canceledAt: null } });

View File

@@ -0,0 +1,125 @@
import { ActionRowBuilder, Attachment, ButtonBuilder, ButtonInteraction, ButtonStyle, ChatInputCommandInteraction, EmbedBuilder } from 'discord.js';
import { prisma } from '../database';
import { settingsStore } from '../config/state';
import { context } from '../config/context';
import { logger } from '../utils/logger';
export class GalleryService {
private timer: NodeJS.Timeout | null = null;
public startScheduler(intervalMs = 24 * 60 * 60 * 1000) {
if (this.timer) clearInterval(this.timer);
this.timer = setInterval(() => this.tick().catch((err) => logger.warn(`gallery scheduler failed: ${err}`)), intervalMs);
}
public stopScheduler() {
if (this.timer) clearInterval(this.timer);
this.timer = null;
}
public async submit(interaction: ChatInputCommandInteraction, attachment: Attachment, caption?: string) {
if (!interaction.guildId || !interaction.guild) return;
const cfg = settingsStore.get(interaction.guildId)?.galleryConfig;
if (!cfg?.channelId) {
await interaction.reply({ content: 'Es ist kein Galerie-Kanal konfiguriert.', ephemeral: true });
return;
}
if (attachment.contentType && !attachment.contentType.startsWith('image/')) {
await interaction.reply({ content: 'Bitte lade ein Bild hoch.', ephemeral: true });
return;
}
const channel = await interaction.guild.channels.fetch(cfg.channelId).catch(() => null);
if (!channel || !channel.isTextBased()) {
await interaction.reply({ content: 'Galerie-Kanal nicht gefunden.', ephemeral: true });
return;
}
const post = await prisma.galleryPost.create({
data: {
guildId: interaction.guildId,
channelId: cfg.channelId,
userId: interaction.user.id,
userTag: interaction.user.tag,
imageUrl: attachment.url,
caption
}
});
const { embed, components } = this.render(interaction.guildId, post.id, attachment.url, interaction.user.tag, caption, 0);
const sent = await (channel as any).send({ embeds: [embed], components });
await prisma.galleryPost.update({ where: { id: post.id }, data: { messageId: sent.id } });
if (cfg.artistRoleId) {
const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null);
if (member && !member.roles.cache.has(cfg.artistRoleId)) {
await member.roles.add(cfg.artistRoleId).catch(() => undefined);
}
}
await interaction.reply({ content: `Dein Bild wurde in ${channel} eingereicht.`, ephemeral: true });
}
public async handleComponent(interaction: ButtonInteraction) {
const [, , postId] = interaction.customId.split(':');
const post = await prisma.galleryPost.findUnique({ where: { id: postId } });
if (!post) {
await interaction.reply({ content: 'Dieser Beitrag existiert nicht mehr.', ephemeral: true });
return;
}
const existing = await prisma.galleryVote.findUnique({ where: { postId_userId: { postId, userId: interaction.user.id } } });
if (existing) {
await prisma.galleryVote.delete({ where: { id: existing.id } });
} else {
await prisma.galleryVote.create({ data: { postId, userId: interaction.user.id } });
}
const count = await prisma.galleryVote.count({ where: { postId } });
const { embed, components } = this.render(post.guildId, post.id, post.imageUrl, post.userTag, post.caption ?? undefined, count);
await interaction.update({ embeds: [embed], components });
}
public async tick() {
for (const [guildId, cfg] of settingsStore.all()) {
const gallery = cfg.galleryConfig;
if (!gallery?.channelId) continue;
const last = gallery.lastWinnerAt ? new Date(gallery.lastWinnerAt) : null;
const due = !last || Date.now() - last.getTime() >= 7 * 24 * 60 * 60 * 1000;
if (!due) continue;
await this.announceWinner(guildId, gallery.channelId, last ?? new Date(0));
await settingsStore.set(guildId, { galleryConfig: { ...gallery, lastWinnerAt: new Date().toISOString() } });
}
}
private async announceWinner(guildId: string, channelId: string, since: Date) {
const posts = await prisma.galleryPost.findMany({ where: { guildId, createdAt: { gte: since } }, include: { votes: true } });
if (!posts.length) return;
const winner = posts.slice().sort((a, b) => b.votes.length - a.votes.length)[0];
if (!winner.votes.length) return;
const guild = context.client?.guilds.cache.get(guildId);
const channel = guild ? await guild.channels.fetch(channelId).catch(() => null) : null;
if (!channel || !channel.isTextBased()) return;
const embed = new EmbedBuilder()
.setTitle('🏆 Kunstwerk der Woche')
.setDescription(`Herzlichen Glückwunsch <@${winner.userId}>! (${winner.votes.length} Stimmen)`)
.setImage(winner.imageUrl)
.setColor(context.branding.getColor(guildId))
.setTimestamp();
context.branding.applyFooter(embed, guildId);
await (channel as any).send({ embeds: [embed] }).catch(() => undefined);
}
private render(guildId: string, postId: string, imageUrl: string, userTag: string, caption: string | undefined, votes: number) {
const embed = new EmbedBuilder()
.setTitle('Neue Einreichung')
.setDescription(caption || '*Keine Beschreibung*')
.setAuthor({ name: userTag })
.setImage(imageUrl)
.setColor(context.branding.getColor(guildId))
.addFields({ name: 'Stimmen', value: String(votes) });
context.branding.applyFooter(embed, guildId);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId(`gallery:vote:${postId}`).setLabel(`▲ Vote (${votes})`).setStyle(ButtonStyle.Secondary)
);
return { embed, components: [row] };
}
}

View File

@@ -0,0 +1,96 @@
import { Guild } from 'discord.js';
import { prisma } from '../database';
import { logger } from '../utils/logger';
export class GrowthService {
private inviteCache = new Map<string, Map<string, number>>();
public async cacheInvites(guild: Guild) {
try {
const invites = await guild.invites.fetch();
const map = new Map<string, number>();
invites.forEach((inv) => map.set(inv.code, inv.uses ?? 0));
this.inviteCache.set(guild.id, map);
} catch (err) {
logger.warn(`Failed to cache invites for ${guild.id}: ${err}`);
}
}
public async resolveUsedInvite(guild: Guild): Promise<string | null> {
const before = this.inviteCache.get(guild.id);
try {
const invites = await guild.invites.fetch();
let usedCode: string | null = null;
invites.forEach((inv) => {
const prevUses = before?.get(inv.code) ?? 0;
if ((inv.uses ?? 0) > prevUses) usedCode = inv.code;
});
if (!usedCode && before) {
for (const code of before.keys()) {
if (!invites.has(code)) {
usedCode = code;
break;
}
}
}
const map = new Map<string, number>();
invites.forEach((inv) => map.set(inv.code, inv.uses ?? 0));
this.inviteCache.set(guild.id, map);
return usedCode;
} catch (err) {
logger.warn(`Failed to resolve used invite for ${guild.id}: ${err}`);
return null;
}
}
public async recordJoin(guildId: string, userId: string, inviteCode: string | null) {
await prisma.guildGrowthEvent.create({ data: { guildId, userId, type: 'join', inviteCode: inviteCode ?? undefined } }).catch(() => undefined);
}
public async recordLeave(guildId: string, userId: string) {
await prisma.guildGrowthEvent.create({ data: { guildId, userId, type: 'leave' } }).catch(() => undefined);
}
public async getStats(guild: Guild) {
const since7 = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const since30 = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const [joins7, leaves7, joins30, leaves30, inviteGroups, acceptedPartners, dailyRows] = await Promise.all([
prisma.guildGrowthEvent.count({ where: { guildId: guild.id, type: 'join', createdAt: { gte: since7 } } }),
prisma.guildGrowthEvent.count({ where: { guildId: guild.id, type: 'leave', createdAt: { gte: since7 } } }),
prisma.guildGrowthEvent.count({ where: { guildId: guild.id, type: 'join', createdAt: { gte: since30 } } }),
prisma.guildGrowthEvent.count({ where: { guildId: guild.id, type: 'leave', createdAt: { gte: since30 } } }),
prisma.guildGrowthEvent.groupBy({
by: ['inviteCode'],
where: { guildId: guild.id, type: 'join', inviteCode: { not: null }, createdAt: { gte: since30 } },
_count: { _all: true }
}),
prisma.partnerRequest.findMany({ where: { guildId: guild.id, status: 'accepted' }, select: { inviteCode: true } }),
prisma.guildGrowthEvent.findMany({ where: { guildId: guild.id, type: 'join', createdAt: { gte: since7 } }, select: { createdAt: true } })
]);
const bestInvite = inviteGroups.slice().sort((a, b) => b._count._all - a._count._all)[0];
const partnerCodes = new Set(acceptedPartners.map((p) => p.inviteCode));
const partnerJoins30 = inviteGroups
.filter((g) => g.inviteCode && partnerCodes.has(g.inviteCode))
.reduce((sum, g) => sum + g._count._all, 0);
const dayBuckets = new Map<string, number>();
dailyRows.forEach((r) => {
const key = r.createdAt.toISOString().slice(0, 10);
dayBuckets.set(key, (dayBuckets.get(key) ?? 0) + 1);
});
return {
joins7,
leaves7,
joins30,
leaves30,
bestInvite: bestInvite ? { code: bestInvite.inviteCode as string, uses: bestInvite._count._all } : null,
partnerJoins30,
boosts: guild.premiumSubscriptionCount ?? 0,
dailyJoins: Array.from(dayBuckets.entries())
.map(([day, count]) => ({ day, count }))
.sort((a, b) => a.day.localeCompare(b.day))
};
}
}

View File

@@ -2,6 +2,7 @@ import { TextChannel, Guild, Message, GuildMember, User, EmbedBuilder, GuildChan
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { settingsStore } from '../config/state'; import { settingsStore } from '../config/state';
import type { AdminService } from './adminService'; import type { AdminService } from './adminService';
import { context } from '../config/context';
let adminSink: AdminService | null = null; let adminSink: AdminService | null = null;
export const setLoggingAdmin = (admin: AdminService) => { export const setLoggingAdmin = (admin: AdminService) => {
@@ -66,6 +67,7 @@ export class LoggingService {
.setDescription(`${member.user.tag} ist dem Server beigetreten.`) .setDescription(`${member.user.tag} ist dem Server beigetreten.`)
.setColor(0x00ff99) .setColor(0x00ff99)
.setTimestamp(); .setTimestamp();
context.branding.applyFooter(embed, member.guild.id);
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log join', err)); channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log join', err));
adminSink?.pushGuildLog({ guildId: member.guild.id, level: 'INFO', message: 'Member joined: ' + member.user.tag, timestamp: Date.now(), category: 'joinLeave' }); adminSink?.pushGuildLog({ guildId: member.guild.id, level: 'INFO', message: 'Member joined: ' + member.user.tag, timestamp: Date.now(), category: 'joinLeave' });
} }
@@ -79,6 +81,7 @@ export class LoggingService {
.setDescription(`${member.user.tag} hat den Server verlassen.`) .setDescription(`${member.user.tag} hat den Server verlassen.`)
.setColor(0xff9900) .setColor(0xff9900)
.setTimestamp(); .setTimestamp();
context.branding.applyFooter(embed, member.guild.id);
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log leave', err)); channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log leave', err));
adminSink?.pushGuildLog({ guildId: member.guild.id, level: 'WARN', message: 'Member left: ' + member.user.tag, timestamp: Date.now(), category: 'joinLeave' }); adminSink?.pushGuildLog({ guildId: member.guild.id, level: 'WARN', message: 'Member left: ' + member.user.tag, timestamp: Date.now(), category: 'joinLeave' });
} }
@@ -94,6 +97,7 @@ export class LoggingService {
.addFields({ name: 'Inhalt', value: this.safeField(message.content) }) .addFields({ name: 'Inhalt', value: this.safeField(message.content) })
.setColor(0xff0000) .setColor(0xff0000)
.setTimestamp(); .setTimestamp();
context.branding.applyFooter(embed, message.guild.id);
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log message delete', err)); channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log message delete', err));
adminSink?.pushGuildLog({ adminSink?.pushGuildLog({
guildId: message.guild.id, guildId: message.guild.id,
@@ -118,6 +122,7 @@ export class LoggingService {
) )
.setColor(0xffff00) .setColor(0xffff00)
.setTimestamp(); .setTimestamp();
context.branding.applyFooter(embed, oldMessage.guild.id);
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log message edit', err)); channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log message edit', err));
adminSink?.pushGuildLog({ adminSink?.pushGuildLog({
guildId: oldMessage.guild.id, guildId: oldMessage.guild.id,
@@ -141,6 +146,7 @@ export class LoggingService {
.addFields({ name: 'Grund', value: this.safeField(reason || 'Nicht angegeben') }) .addFields({ name: 'Grund', value: this.safeField(reason || 'Nicht angegeben') })
.setColor(0x7289da) .setColor(0x7289da)
.setTimestamp(); .setTimestamp();
context.branding.applyFooter(embed, resolvedGuild.id);
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log action', err)); channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log action', err));
const guildId = resolvedGuild.id; const guildId = resolvedGuild.id;
if (guildId) { if (guildId) {
@@ -174,6 +180,7 @@ export class LoggingService {
if (options.messageUrl) { if (options.messageUrl) {
embed.addFields({ name: 'Link', value: options.messageUrl }); embed.addFields({ name: 'Link', value: options.messageUrl });
} }
context.branding.applyFooter(embed, guild.id);
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log automod action', err)); channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log automod action', err));
adminSink?.pushGuildLog({ adminSink?.pushGuildLog({
guildId: guild.id, guildId: guild.id,
@@ -185,6 +192,55 @@ export class LoggingService {
adminSink?.trackGuildEvent(guild.id, 'automod'); adminSink?.trackGuildEvent(guild.id, 'automod');
} }
logWatchlistEvent(guild: Guild, options: { userId: string; userTag?: string; eventLabel: string; detail?: string }) {
const { channel } = this.resolve(guild);
if (!channel) return;
const embed = new EmbedBuilder()
.setTitle('Watchlist-Alarm')
.setDescription(`<@${options.userId}> (${options.userTag ?? options.userId}) — ${options.eventLabel}`)
.setColor(0xeab308)
.setTimestamp();
if (options.detail) embed.addFields({ name: 'Details', value: this.safeField(options.detail) });
context.branding.applyFooter(embed, guild.id);
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log watchlist event', err));
adminSink?.pushGuildLog({
guildId: guild.id,
level: 'WARN',
message: `Watchlist: ${options.eventLabel} (${options.userTag ?? options.userId})`,
timestamp: Date.now(),
category: 'automodActions'
});
}
logSuspiciousJoin(member: GuildMember, reasons: string[]) {
const { channel } = this.resolve(member.guild);
if (!channel) return;
const embed = new EmbedBuilder()
.setTitle('⚠️ Neuer Account wirkt verdächtig. Bitte prüfen')
.setDescription(`${member.user.tag} (<@${member.id}>)`)
.addFields({ name: 'Gründe', value: reasons.map((r) => `${r}`).join('\n') })
.setColor(0xdc2626)
.setTimestamp();
context.branding.applyFooter(embed, member.guild.id);
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log suspicious join', err));
adminSink?.pushGuildLog({
guildId: member.guild.id,
level: 'WARN',
message: `Verdächtiger Join: ${member.user.tag} (${reasons.join(', ')})`,
timestamp: Date.now(),
category: 'joinLeave'
});
}
logPartnerEvent(guild: Guild, message: string) {
const { channel } = this.resolve(guild);
if (!channel) return;
const embed = new EmbedBuilder().setTitle('Partner-System').setDescription(message).setColor(context.branding.getColor(guild.id)).setTimestamp();
context.branding.applyFooter(embed, guild.id);
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log partner event', err));
adminSink?.pushGuildLog({ guildId: guild.id, level: 'INFO', message: `Partner: ${message}`, timestamp: Date.now(), category: 'system' });
}
logRoleUpdate(member: GuildMember, added: string[], removed: string[]) { logRoleUpdate(member: GuildMember, added: string[], removed: string[]) {
const guildId = member.guild.id; const guildId = member.guild.id;
adminSink?.pushGuildLog({ adminSink?.pushGuildLog({

View File

@@ -0,0 +1,26 @@
import { prisma } from '../database';
export type ModCaseType = 'warn' | 'mute' | 'timeout' | 'kick' | 'ban' | 'tempban' | 'note' | 'watchlist_add' | 'watchlist_remove';
export class ModCaseService {
public async recordCase(guildId: string, userId: string, type: ModCaseType, reason: string | undefined, moderatorId: string, moderatorTag: string) {
return prisma.modCase.create({ data: { guildId, userId, type, reason, moderatorId, moderatorTag } });
}
public async addNote(guildId: string, userId: string, moderatorId: string, moderatorTag: string, body: string) {
return this.recordCase(guildId, userId, 'note', body, moderatorId, moderatorTag);
}
public async getCase(guildId: string, userId: string) {
const [cases, ticketCount, strikeAgg] = await Promise.all([
prisma.modCase.findMany({ where: { guildId, userId }, orderBy: { createdAt: 'desc' } }),
prisma.ticket.count({ where: { guildId, userId } }),
prisma.automodStrike.aggregate({ where: { guildId, userId }, _sum: { weight: true } })
]);
const counts: Record<string, number> = {};
cases.forEach((c) => {
counts[c.type] = (counts[c.type] || 0) + 1;
});
return { cases, counts, ticketCount, automodStrikes: strikeAgg._sum.weight || 0 };
}
}

View File

@@ -13,7 +13,8 @@ export type ModuleKey =
| 'eventsEnabled' | 'eventsEnabled'
| 'registerEnabled' | 'registerEnabled'
| 'serverStatsEnabled' | 'serverStatsEnabled'
| 'tasksEnabled'; | 'tasksEnabled'
| 'badgesEnabled';
export interface GuildModuleState { export interface GuildModuleState {
key: ModuleKey; key: ModuleKey;
@@ -35,7 +36,8 @@ const MODULES: Record<ModuleKey, { name: string; description: string }> = {
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.' } tasksEnabled: { name: 'Team-Aufgaben', description: 'Aufgaben für das Team erstellen und Status per Buttons pflegen.' },
badgesEnabled: { name: 'Badges', description: 'Vergibt Abzeichen für Aktivität und Meilensteine.' }
}; };
export class BotModuleService { export class BotModuleService {

View File

@@ -0,0 +1,149 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonInteraction,
ButtonStyle,
ChatInputCommandInteraction,
EmbedBuilder,
ModalBuilder,
ModalSubmitInteraction,
PermissionFlagsBits,
TextInputBuilder,
TextInputStyle
} from 'discord.js';
import { prisma } from '../database';
import { settingsStore } from '../config/state';
import { context } from '../config/context';
function extractInviteCode(raw: string): string {
const trimmed = raw.trim();
const match = trimmed.match(/(?:discord\.gg\/|discord(?:app)?\.com\/invite\/)?([a-z0-9-]+)$/i);
return match ? match[1] : trimmed;
}
export class PartnerService {
public async openApplyModal(interaction: ChatInputCommandInteraction) {
const modal = new ModalBuilder().setCustomId('partner:apply').setTitle('Partner-Bewerbung');
const serverName = new TextInputBuilder().setCustomId('server_name').setLabel('Server-Name').setStyle(TextInputStyle.Short).setRequired(true);
const invite = new TextInputBuilder().setCustomId('invite').setLabel('Invite-Link oder Code').setStyle(TextInputStyle.Short).setRequired(true);
const members = new TextInputBuilder().setCustomId('members').setLabel('Mitgliederzahl').setStyle(TextInputStyle.Short).setRequired(false);
const description = new TextInputBuilder().setCustomId('description').setLabel('Beschreibung').setStyle(TextInputStyle.Paragraph).setRequired(false);
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(serverName),
new ActionRowBuilder<TextInputBuilder>().addComponents(invite),
new ActionRowBuilder<TextInputBuilder>().addComponents(members),
new ActionRowBuilder<TextInputBuilder>().addComponents(description)
);
await interaction.showModal(modal);
}
public async handleModal(interaction: ModalSubmitInteraction) {
if (interaction.customId !== 'partner:apply' || !interaction.guildId) return;
const serverName = interaction.fields.getTextInputValue('server_name').trim();
const inviteCode = extractInviteCode(interaction.fields.getTextInputValue('invite'));
const memberCountInput = parseInt(interaction.fields.getTextInputValue('members'), 10);
const description = interaction.fields.getTextInputValue('description').trim() || undefined;
let inviteValid = false;
let liveMemberCount: number | undefined;
let liveGuildName: string | undefined;
try {
const invite = await interaction.client.fetchInvite(inviteCode);
inviteValid = true;
liveMemberCount = invite.memberCount ?? undefined;
liveGuildName = invite.guild?.name;
} catch {
inviteValid = false;
}
const request = await prisma.partnerRequest.create({
data: {
guildId: interaction.guildId,
userId: interaction.user.id,
userTag: interaction.user.tag,
serverName,
inviteCode,
memberCount: Number.isFinite(memberCountInput) ? memberCountInput : liveMemberCount,
description
}
});
const cfg = settingsStore.get(interaction.guildId);
const reviewChannelId = cfg?.partnerConfig?.reviewChannelId;
if (reviewChannelId) {
const guild = interaction.guild;
const channel = guild ? await guild.channels.fetch(reviewChannelId).catch(() => null) : null;
if (channel && channel.isTextBased()) {
const warnings: string[] = [];
if (!inviteValid) warnings.push('⚠️ Invite konnte nicht aufgelöst werden.');
if (inviteValid && liveGuildName && liveGuildName.toLowerCase() !== serverName.toLowerCase()) {
warnings.push(`⚠️ Server-Name im Invite weicht ab (${liveGuildName}).`);
}
const embed = new EmbedBuilder()
.setTitle(`Partner-Bewerbung: ${serverName}`)
.setColor(context.branding.getColor(interaction.guildId))
.addFields(
{ name: 'Bewerber', value: interaction.user.tag, inline: true },
{ name: 'Invite', value: `discord.gg/${inviteCode}`, inline: true },
{ name: 'Mitglieder', value: String(request.memberCount ?? liveMemberCount ?? 'Unbekannt'), inline: true },
{ name: 'Beschreibung', value: description || 'Keine Beschreibung' }
);
if (warnings.length) embed.addFields({ name: 'Automatische Prüfung', value: warnings.join('\n') });
context.branding.applyFooter(embed, interaction.guildId);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId(`partner:decide:${request.id}:accepted`).setLabel('Annehmen').setStyle(ButtonStyle.Success),
new ButtonBuilder().setCustomId(`partner:decide:${request.id}:rejected`).setLabel('Ablehnen').setStyle(ButtonStyle.Danger)
);
await (channel as any).send({ embeds: [embed], components: [row] });
}
}
await interaction.reply({ content: 'Deine Partner-Bewerbung wurde eingereicht.', ephemeral: true });
}
public async handleComponent(interaction: ButtonInteraction) {
const [, , requestId, decision] = interaction.customId.split(':');
if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) {
await interaction.reply({ content: 'Du benötigst die Berechtigung "Server verwalten".', ephemeral: true });
return;
}
const request = await prisma.partnerRequest.findUnique({ where: { id: requestId } });
if (!request) {
await interaction.reply({ content: 'Diese Bewerbung existiert nicht mehr.', ephemeral: true });
return;
}
const status = decision === 'accepted' ? 'accepted' : 'rejected';
await prisma.partnerRequest.update({ where: { id: requestId }, data: { status, reviewedBy: interaction.user.id } });
if (status === 'accepted' && interaction.guild) {
const cfg = settingsStore.get(request.guildId);
const showcaseChannelId = cfg?.partnerConfig?.showcaseChannelId;
if (showcaseChannelId) {
const channel = await interaction.guild.channels.fetch(showcaseChannelId).catch(() => null);
if (channel && channel.isTextBased()) {
const embed = new EmbedBuilder()
.setTitle(`🤝 Partner: ${request.serverName}`)
.setDescription(request.description || '')
.setColor(context.branding.getColor(request.guildId))
.addFields({ name: 'Invite', value: `discord.gg/${request.inviteCode}` });
context.branding.applyFooter(embed, request.guildId);
await (channel as any).send({ embeds: [embed] });
}
}
}
if (interaction.guild) {
context.logging.logPartnerEvent(interaction.guild, `${request.serverName} wurde von ${interaction.user.tag} ${status === 'accepted' ? 'angenommen' : 'abgelehnt'}.`);
}
const original = interaction.message.embeds[0];
await interaction.update({
embeds: original ? [EmbedBuilder.from(original).setFooter({ text: `Status: ${status} von ${interaction.user.tag}` })] : [],
components: []
});
}
public async list(guildId: string) {
return prisma.partnerRequest.findMany({ where: { guildId, status: 'accepted' }, orderBy: { createdAt: 'desc' } });
}
}

156
src/services/pollService.ts Normal file
View File

@@ -0,0 +1,156 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonInteraction,
ButtonStyle,
ChatInputCommandInteraction,
Client,
EmbedBuilder,
TextChannel
} from 'discord.js';
import { prisma } from '../database';
import { logger } from '../utils/logger';
interface PollOption {
id: string;
label: string;
}
export class PollService {
private client: Client | null = null;
private timer: NodeJS.Timeout | null = null;
public setClient(client: Client) {
this.client = client;
}
public startScheduler(intervalMs = 60000) {
if (this.timer) clearInterval(this.timer);
const interval = Math.max(30000, intervalMs);
this.timer = setInterval(() => this.tick().catch((err) => logger.warn('poll scheduler failed', err)), interval);
}
public stopScheduler() {
if (this.timer) clearInterval(this.timer);
this.timer = null;
}
public async tick() {
const due = await prisma.poll.findMany({ where: { closed: false, closesAt: { lte: new Date() } } });
for (const poll of due) {
await this.close(poll.id);
}
}
public async create(
interaction: ChatInputCommandInteraction,
channelId: string,
question: string,
optionLabels: string[],
anonymous: boolean,
closesInMinutes?: number
) {
if (optionLabels.length < 2 || optionLabels.length > 5) {
await interaction.reply({ content: 'Bitte 2 bis 5 Optionen angeben (komma-getrennt).', ephemeral: true });
return;
}
const channel = await interaction.guild?.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isTextBased()) {
await interaction.reply({ content: 'Zielkanal nicht gefunden.', ephemeral: true });
return;
}
const options: PollOption[] = optionLabels.map((label, idx) => ({ id: String(idx), label: label.trim() }));
const closesAt = closesInMinutes ? new Date(Date.now() + closesInMinutes * 60000) : null;
const poll = await prisma.poll.create({
data: {
guildId: interaction.guildId!,
channelId,
question,
options: options as any,
anonymous,
closesAt,
createdBy: interaction.user.id
}
});
const { embed, components } = await this.render(poll.id, question, options, anonymous, false, closesAt);
const sent = await (channel as TextChannel).send({ embeds: [embed], components });
await prisma.poll.update({ where: { id: poll.id }, data: { messageId: sent.id } });
await interaction.reply({ content: `Umfrage wurde in ${channel} gepostet.`, ephemeral: true });
}
public async handleComponent(interaction: ButtonInteraction) {
const [, , pollId, optionId] = interaction.customId.split(':');
const poll = await prisma.poll.findUnique({ where: { id: pollId } });
if (!poll || poll.closed) {
await interaction.reply({ content: 'Diese Umfrage ist nicht mehr aktiv.', ephemeral: true });
return;
}
const existing = await prisma.pollVote.findUnique({ where: { pollId_userId: { pollId, userId: interaction.user.id } } });
if (existing && existing.optionId === optionId) {
await prisma.pollVote.delete({ where: { id: existing.id } });
} else {
await prisma.pollVote.upsert({
where: { pollId_userId: { pollId, userId: interaction.user.id } },
update: { optionId },
create: { pollId, userId: interaction.user.id, optionId }
});
}
const options = poll.options as unknown as PollOption[];
const { embed, components } = await this.render(poll.id, poll.question, options, poll.anonymous, false, poll.closesAt);
await interaction.update({ embeds: [embed], components });
}
public async close(pollId: string) {
const poll = await prisma.poll.findUnique({ where: { id: pollId } });
if (!poll || poll.closed) return;
await prisma.poll.update({ where: { id: pollId }, data: { closed: true } });
const options = poll.options as unknown as PollOption[];
const { embed } = await this.render(poll.id, poll.question, options, poll.anonymous, true, poll.closesAt);
if (!poll.messageId || !this.client) return;
const channel = await this.client.channels.fetch(poll.channelId).catch(() => null);
if (!channel || !channel.isTextBased()) return;
const message = await (channel as TextChannel).messages.fetch(poll.messageId).catch(() => null);
await message?.edit({ embeds: [embed], components: [] }).catch(() => undefined);
}
private async render(pollId: string, question: string, options: PollOption[], anonymous: boolean, closed: boolean, closesAt: Date | null) {
const votes = await prisma.pollVote.findMany({ where: { pollId } });
const counts: Record<string, number> = {};
options.forEach((o) => (counts[o.id] = 0));
votes.forEach((v) => {
counts[v.optionId] = (counts[v.optionId] || 0) + 1;
});
const total = votes.length;
const showCounts = closed || !anonymous;
const embed = new EmbedBuilder()
.setTitle(closed ? `📊 ${question} (beendet)` : `📊 ${question}`)
.setColor(closed ? 0x6b7280 : 0xf97316)
.setDescription(
options
.map((o) => {
if (!showCounts) return `**${o.label}**`;
const pct = total ? Math.round(((counts[o.id] || 0) / total) * 100) : 0;
const bar = '█'.repeat(Math.round(pct / 10)).padEnd(10, '░');
return `**${o.label}** — ${counts[o.id] || 0} Stimme(n) (${pct}%)\n${bar}`;
})
.join('\n\n')
);
if (closesAt && !closed) embed.setFooter({ text: `Schließt automatisch am ${closesAt.toLocaleString('de-DE')}` });
if (anonymous) embed.setFooter({ text: (embed.data.footer?.text ? embed.data.footer.text + ' · ' : '') + 'Anonyme Umfrage' });
const components = closed
? []
: [new ActionRowBuilder<ButtonBuilder>().addComponents(...options.map((o) => new ButtonBuilder().setCustomId(`poll:vote:${pollId}:${o.id}`).setLabel(o.label.slice(0, 30)).setStyle(ButtonStyle.Secondary)))];
return { embed, components };
}
}

View File

@@ -14,6 +14,7 @@ import {
import { prisma } from '../database'; import { prisma } from '../database';
import { settingsStore } from '../config/state'; import { settingsStore } from '../config/state';
import { env } from '../config/env'; import { env } from '../config/env';
import { context } from '../config/context';
export class RegisterService { export class RegisterService {
private client: Client | null = null; private client: Client | null = null;
@@ -103,7 +104,8 @@ export class RegisterService {
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(form.name) .setTitle(form.name)
.setDescription(message || 'Klicke auf Registrieren, um das Formular auszufüllen.') .setDescription(message || 'Klicke auf Registrieren, um das Formular auszufüllen.')
.setColor(0xf97316); .setColor(context.branding.getColor(guildId));
context.branding.applyFooter(embed, guildId);
const btn = new ButtonBuilder().setCustomId(`register:form:${form.id}`).setLabel('Registrieren').setStyle(ButtonStyle.Primary); const btn = new ButtonBuilder().setCustomId(`register:form:${form.id}`).setLabel('Registrieren').setStyle(ButtonStyle.Primary);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(btn); const row = new ActionRowBuilder<ButtonBuilder>().addComponents(btn);
const sent = await (channel as any).send({ embeds: [embed], components: [row] }); const sent = await (channel as any).send({ embeds: [embed], components: [row] });
@@ -205,7 +207,7 @@ export class RegisterService {
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(`Registrierung: ${form.name}`) .setTitle(`Registrierung: ${form.name}`)
.setDescription(form.description || '') .setDescription(form.description || '')
.setColor(0xf97316) .setColor(context.branding.getColor(guildId))
.addFields( .addFields(
...fields.map((f) => ({ ...fields.map((f) => ({
name: f.label, name: f.label,

View File

@@ -0,0 +1,41 @@
interface RuleOptions {
serverType: string;
minAge?: number;
adsAllowed: boolean;
language: string;
strictness: 'locker' | 'streng';
}
export class RulesService {
public generate(options: RuleOptions): { title: string; description: string; rules: string[] } {
const strict = options.strictness === 'streng';
const rules: string[] = [
`Sei respektvoll gegenüber allen Mitgliedern von ${options.serverType || 'diesem Server'}.`,
`Kommunikation erfolgt hauptsächlich auf ${options.language || 'Deutsch'}.`,
'Keine Beleidigungen, Diskriminierung oder Hassrede.',
'Kein Spam, keine Massen-Erwähnungen und kein Flooding.',
'NSFW-Inhalte sind außerhalb dafür vorgesehener Kanäle verboten.'
];
if (options.minAge) rules.push(`Mindestalter zur Teilnahme: ${options.minAge} Jahre.`);
rules.push(
options.adsAllowed
? 'Werbung ist nur nach vorheriger Rücksprache mit dem Team erlaubt.'
: 'Werbung für andere Server oder Produkte ist nicht erlaubt.'
);
rules.push('Anweisungen des Teams sind zu befolgen.');
rules.push(
strict
? 'Verstöße werden konsequent geahndet, wiederholtes Fehlverhalten führt zum Ausschluss.'
: 'Bei kleineren Verstößen gibt es zunächst eine freundliche Erinnerung.'
);
rules.push('Bei Fragen oder Problemen wende dich jederzeit an das Team.');
return {
title: `Regelwerk ${options.serverType || 'Server'}`,
description: `Automatisch generiertes Regelwerk (Strenge: ${strict ? 'streng' : 'locker'}).`,
rules
};
}
}

View File

@@ -35,7 +35,9 @@ const SERVER_TYPES: { key: string; label: string; presetModules: ModuleKey[] }[]
{ key: 'gaming', label: 'Gaming', presetModules: ['welcomeEnabled', 'levelingEnabled', 'musicEnabled', 'dynamicVoiceEnabled', 'eventsEnabled'] }, { key: 'gaming', label: 'Gaming', presetModules: ['welcomeEnabled', 'levelingEnabled', 'musicEnabled', 'dynamicVoiceEnabled', 'eventsEnabled'] },
{ key: 'support', label: 'Support', presetModules: ['ticketsEnabled', 'statuspageEnabled', 'automodEnabled'] }, { key: 'support', label: 'Support', presetModules: ['ticketsEnabled', 'statuspageEnabled', 'automodEnabled'] },
{ key: 'club', label: 'Club', presetModules: ['registerEnabled', 'ticketsEnabled', 'eventsEnabled', 'welcomeEnabled'] }, { key: 'club', label: 'Club', presetModules: ['registerEnabled', 'ticketsEnabled', 'eventsEnabled', 'welcomeEnabled'] },
{ key: 'team', label: 'Team', presetModules: ['ticketsEnabled', 'automodEnabled', 'welcomeEnabled'] } { key: 'team', label: 'Team', presetModules: ['ticketsEnabled', 'automodEnabled', 'welcomeEnabled'] },
{ key: 'roleplay', label: 'Roleplay', presetModules: ['ticketsEnabled', 'registerEnabled', 'eventsEnabled', 'welcomeEnabled'] },
{ key: 'creator', label: 'Creator', presetModules: ['welcomeEnabled', 'eventsEnabled', 'reactionRolesEnabled'] }
]; ];
const STEP_COUNT = 7; const STEP_COUNT = 7;
@@ -244,15 +246,21 @@ export class SetupWizardService {
if (state.step === 0) { if (state.step === 0) {
embed.setTitle('Setup Server-Art').setDescription('Wähle die Art deines Servers, um passende Module vorzuschlagen.'); embed.setTitle('Setup Server-Art').setDescription('Wähle die Art deines Servers, um passende Module vorzuschlagen.');
const row = new ActionRowBuilder<ButtonBuilder>().addComponents( const typeRows: ActionRowBuilder<ButtonBuilder>[] = [];
...SERVER_TYPES.map((t) => for (let i = 0; i < SERVER_TYPES.length; i += 5) {
new ButtonBuilder() const chunk = SERVER_TYPES.slice(i, i + 5);
.setCustomId(`setup:type:${t.key}`) typeRows.push(
.setLabel(t.label) new ActionRowBuilder<ButtonBuilder>().addComponents(
.setStyle(state.serverType === t.key ? ButtonStyle.Primary : ButtonStyle.Secondary) ...chunk.map((t) =>
) new ButtonBuilder()
); .setCustomId(`setup:type:${t.key}`)
return { embeds: [embed], components: [row, cancelRow] }; .setLabel(t.label)
.setStyle(state.serverType === t.key ? ButtonStyle.Primary : ButtonStyle.Secondary)
)
)
);
}
return { embeds: [embed], components: [...typeRows, cancelRow] };
} }
if (state.step === 1) { if (state.step === 1) {

View File

@@ -0,0 +1,126 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonInteraction,
ButtonStyle,
ChatInputCommandInteraction,
EmbedBuilder,
PermissionFlagsBits,
TextChannel
} from 'discord.js';
import { prisma } from '../database';
const STATUS_LABELS: Record<string, string> = { pending: 'Offen', accepted: 'Angenommen', rejected: 'Abgelehnt' };
const STATUS_COLORS: Record<string, number> = { pending: 0xf97316, accepted: 0x22c55e, rejected: 0xef4444 };
export class SuggestionService {
public async create(interaction: ChatInputCommandInteraction, content: string) {
if (!interaction.guildId || !interaction.channel?.isTextBased()) return;
const suggestion = await prisma.suggestion.create({
data: {
guildId: interaction.guildId,
channelId: interaction.channelId,
userId: interaction.user.id,
userTag: interaction.user.tag,
content
}
});
const { embed, components } = await this.render(suggestion.id, content, interaction.user.tag, 'pending');
const sent = await (interaction.channel as TextChannel).send({ embeds: [embed], components });
await prisma.suggestion.update({ where: { id: suggestion.id }, data: { messageId: sent.id } });
await interaction.reply({ content: 'Danke für deinen Vorschlag!', ephemeral: true });
}
public async handleComponent(interaction: ButtonInteraction) {
const [, action, suggestionId, extra] = interaction.customId.split(':');
const suggestion = await prisma.suggestion.findUnique({ where: { id: suggestionId } });
if (!suggestion) {
await interaction.reply({ content: 'Dieser Vorschlag existiert nicht mehr.', ephemeral: true });
return;
}
if (action === 'vote') {
if (suggestion.status !== 'pending') {
await interaction.reply({ content: 'Über diesen Vorschlag wurde bereits entschieden.', ephemeral: true });
return;
}
const value = extra === '1' ? 1 : -1;
const existing = await prisma.suggestionVote.findUnique({ where: { suggestionId_userId: { suggestionId, userId: interaction.user.id } } });
if (existing && existing.value === value) {
await prisma.suggestionVote.delete({ where: { id: existing.id } });
} else {
await prisma.suggestionVote.upsert({
where: { suggestionId_userId: { suggestionId, userId: interaction.user.id } },
update: { value },
create: { suggestionId, userId: interaction.user.id, value }
});
}
const { embed, components } = await this.render(suggestion.id, suggestion.content, suggestion.userTag, suggestion.status);
await interaction.update({ embeds: [embed], components });
return;
}
if (action === 'comment') {
let thread = suggestion.messageId ? await interaction.guild?.channels.fetch(suggestion.messageId).catch(() => null) : null;
if (!thread || !thread.isThread()) {
thread = await interaction.message.startThread({ name: 'Diskussion' }).catch(() => null);
}
if (!thread) {
await interaction.reply({ content: 'Thread konnte nicht erstellt werden.', ephemeral: true });
return;
}
await interaction.reply({ content: `Diskutiere hier weiter: ${thread}`, ephemeral: true });
return;
}
if (action === 'decide') {
const canDecide = interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild);
if (!canDecide) {
await interaction.reply({ content: 'Du benötigst die Berechtigung "Server verwalten".', ephemeral: true });
return;
}
const status = extra === 'accepted' ? 'accepted' : 'rejected';
await prisma.suggestion.update({ where: { id: suggestionId }, data: { status } });
const { embed, components } = await this.render(suggestion.id, suggestion.content, suggestion.userTag, status, interaction.user.tag);
await interaction.update({ embeds: [embed], components });
}
}
private async render(suggestionId: string, content: string, userTag: string, status: string, decidedBy?: string) {
const votes = await prisma.suggestionVote.findMany({ where: { suggestionId } });
const forCount = votes.filter((v) => v.value === 1).length;
const againstCount = votes.filter((v) => v.value === -1).length;
const embed = new EmbedBuilder()
.setTitle(`Vorschlag von ${userTag}`)
.setDescription(content)
.setColor(STATUS_COLORS[status] ?? 0xf97316)
.addFields(
{ name: '✅ Dafür', value: String(forCount), inline: true },
{ name: '❌ Dagegen', value: String(againstCount), inline: true },
{ name: 'Status', value: STATUS_LABELS[status] ?? status, inline: true }
);
if (decidedBy) embed.setFooter({ text: `Entschieden von ${decidedBy}` });
const components =
status === 'pending'
? [
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId(`suggestion:vote:${suggestionId}:1`).setLabel('Dafür').setEmoji('✅').setStyle(ButtonStyle.Success),
new ButtonBuilder().setCustomId(`suggestion:vote:${suggestionId}:-1`).setLabel('Dagegen').setEmoji('❌').setStyle(ButtonStyle.Danger),
new ButtonBuilder().setCustomId(`suggestion:comment:${suggestionId}`).setLabel('Kommentar').setEmoji('💬').setStyle(ButtonStyle.Secondary)
),
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId(`suggestion:decide:${suggestionId}:accepted`).setLabel('Annehmen').setEmoji('📌').setStyle(ButtonStyle.Success),
new ButtonBuilder().setCustomId(`suggestion:decide:${suggestionId}:rejected`).setLabel('Ablehnen').setStyle(ButtonStyle.Danger)
)
]
: [
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId(`suggestion:comment:${suggestionId}`).setLabel('Kommentar').setEmoji('💬').setStyle(ButtonStyle.Secondary)
)
];
return { embed, components };
}
}

View File

@@ -305,7 +305,8 @@ export class TicketService {
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(`Ticket: ${topic}`) .setTitle(`Ticket: ${topic}`)
.setDescription('Ein Teammitglied wird sich gleich kümmern. Nutze `/claim`, um den Fall zu übernehmen.') .setDescription('Ein Teammitglied wird sich gleich kümmern. Nutze `/claim`, um den Fall zu übernehmen.')
.setColor(0x7289da); .setColor(context.branding.getColor(guild.id));
context.branding.applyFooter(embed, guild.id);
const controls = new ActionRowBuilder<ButtonBuilder>().addComponents( const controls = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId('ticket:claim').setLabel('Claim').setStyle(ButtonStyle.Primary), new ButtonBuilder().setCustomId('ticket:claim').setLabel('Claim').setStyle(ButtonStyle.Primary),
@@ -323,6 +324,9 @@ export class TicketService {
}); });
await this.sendTicketCreatedLog(guild, channel, member, topic, nextNumber, supportRoleId); await this.sendTicketCreatedLog(guild, channel, member, topic, nextNumber, supportRoleId);
await this.runAutomations({ ...record }); await this.runAutomations({ ...record });
const priorTickets = await prisma.ticket.count({ where: { guildId: guild.id, userId: member.id } });
if (priorTickets === 1) await context.badges.award(guild.id, member.id, 'first_ticket');
context.watchlist.notifyIfWatched(guild, member.id, 'Ticket erstellt', topic);
return record as TicketRecord; return record as TicketRecord;
} }

View File

@@ -0,0 +1,48 @@
import { Guild } from 'discord.js';
import { prisma } from '../database';
import { LoggingService } from './loggingService';
export class WatchlistService {
private active = new Set<string>();
constructor(private logging: LoggingService) {}
private key(guildId: string, userId: string) {
return `${guildId}:${userId}`;
}
public async loadAll() {
const rows = await prisma.watchlist.findMany({ where: { active: true } });
this.active = new Set(rows.map((r) => this.key(r.guildId, r.userId)));
}
public async add(guildId: string, userId: string, reason: string | undefined, addedBy: string) {
await prisma.watchlist.upsert({
where: { guildId_userId: { guildId, userId } },
update: { active: true, reason, addedBy, addedAt: new Date() },
create: { guildId, userId, reason, addedBy }
});
this.active.add(this.key(guildId, userId));
}
public async remove(guildId: string, userId: string) {
await prisma.watchlist.updateMany({ where: { guildId, userId }, data: { active: false } });
this.active.delete(this.key(guildId, userId));
}
public isWatched(guildId: string, userId: string) {
return this.active.has(this.key(guildId, userId));
}
public async list(guildId: string) {
return prisma.watchlist.findMany({ where: { guildId, active: true }, orderBy: { addedAt: 'desc' } });
}
public notifyIfWatched(guild: Guild | null, userId: string, eventLabel: string, detail?: string) {
if (!guild || !this.isWatched(guild.id, userId)) return;
guild.members
.fetch(userId)
.then((member) => this.logging.logWatchlistEvent(guild, { userId, userTag: member.user.tag, eventLabel, detail }))
.catch(() => this.logging.logWatchlistEvent(guild, { userId, eventLabel, detail }));
}
}

View File

@@ -0,0 +1,123 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonInteraction,
ButtonStyle,
ChatInputCommandInteraction,
EmbedBuilder,
ModalBuilder,
ModalSubmitInteraction,
TextInputBuilder,
TextInputStyle
} from 'discord.js';
import { prisma } from '../database';
interface PlanEntry {
id: string;
day: string;
title: string;
}
export class WeeklyPlanService {
public async openModal(interaction: ChatInputCommandInteraction, channelId: string) {
const modal = new ModalBuilder().setCustomId(`weekplan:create:${channelId}`).setTitle('Wochenplan erstellen');
const title = new TextInputBuilder().setCustomId('title').setLabel('Titel').setStyle(TextInputStyle.Short).setValue('Wochenplan').setRequired(true);
const entries = new TextInputBuilder()
.setCustomId('entries')
.setLabel('Einträge (ein "Tag: Titel" pro Zeile)')
.setStyle(TextInputStyle.Paragraph)
.setPlaceholder('Montag: Gaming-Abend\nMittwoch: Community Talk\nFreitag: Event\nSonntag: Besprechung')
.setRequired(true);
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(title), new ActionRowBuilder<TextInputBuilder>().addComponents(entries));
await interaction.showModal(modal);
}
public async handleModal(interaction: ModalSubmitInteraction) {
if (!interaction.customId.startsWith('weekplan:create:')) return;
if (!interaction.guildId) return;
const channelId = interaction.customId.split(':')[2];
const channel = await interaction.guild?.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isTextBased()) {
await interaction.reply({ content: 'Zielkanal nicht gefunden.', ephemeral: true });
return;
}
const title = interaction.fields.getTextInputValue('title') || 'Wochenplan';
const rawEntries = interaction.fields.getTextInputValue('entries');
const entries: PlanEntry[] = rawEntries
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.slice(0, 10)
.map((line, idx) => {
const [day, ...rest] = line.split(':');
return { id: String(idx), day: (day || `Eintrag ${idx + 1}`).trim(), title: rest.join(':').trim() || 'Ohne Titel' };
});
if (!entries.length) {
await interaction.reply({ content: 'Bitte mindestens einen Eintrag im Format "Tag: Titel" angeben.', ephemeral: true });
return;
}
const plan = await prisma.weeklyPlan.create({
data: { guildId: interaction.guildId, channelId, title, entries: entries as any }
});
const { embed, components } = this.render(plan.id, title, entries, {});
const sent = await (channel as any).send({ embeds: [embed], components });
await prisma.weeklyPlan.update({ where: { id: plan.id }, data: { messageId: sent.id } });
await interaction.reply({ content: `Wochenplan wurde in ${channel} gepostet.`, ephemeral: true });
}
public async handleComponent(interaction: ButtonInteraction) {
const [, , planId, entryId] = interaction.customId.split(':');
const plan = await prisma.weeklyPlan.findUnique({ where: { id: planId } });
if (!plan) {
await interaction.reply({ content: 'Dieser Wochenplan existiert nicht mehr.', ephemeral: true });
return;
}
const existing = await prisma.weeklyPlanRsvp.findFirst({ where: { planId, entryId, userId: interaction.user.id } });
if (existing) {
await prisma.weeklyPlanRsvp.delete({ where: { id: existing.id } });
} else {
await prisma.weeklyPlanRsvp.create({ data: { planId, entryId, userId: interaction.user.id } });
}
const entries = plan.entries as unknown as PlanEntry[];
const counts = await this.getCounts(planId);
const { embed, components } = this.render(plan.id, plan.title, entries, counts);
await interaction.update({ embeds: [embed], components });
}
private async getCounts(planId: string): Promise<Record<string, number>> {
const rows = await prisma.weeklyPlanRsvp.groupBy({ by: ['entryId'], where: { planId }, _count: { _all: true } });
const counts: Record<string, number> = {};
rows.forEach((r) => {
counts[r.entryId] = r._count._all;
});
return counts;
}
private render(planId: string, title: string, entries: PlanEntry[], counts: Record<string, number>) {
const embed = new EmbedBuilder()
.setTitle(title)
.setColor(0xf97316)
.setDescription(entries.map((e) => `**${e.day}**: ${e.title}${counts[e.id] || 0} Zusage(n)`).join('\n'));
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
for (let i = 0; i < entries.length; i += 5) {
const chunk = entries.slice(i, i + 5);
rows.push(
new ActionRowBuilder<ButtonBuilder>().addComponents(
...chunk.map((e) =>
new ButtonBuilder().setCustomId(`weekplan:rsvp:${planId}:${e.id}`).setLabel(e.day.slice(0, 30)).setStyle(ButtonStyle.Secondary)
)
)
);
}
return { embed, components: rows };
}
}

View File

@@ -73,6 +73,7 @@ router.get('/guild/info', requireAuth, async (req, res) => {
name: guild.name, name: guild.name,
icon: guild.icon, icon: guild.icon,
memberCount: guild.memberCount, memberCount: guild.memberCount,
boostCount: guild.premiumSubscriptionCount ?? 0,
createdAt: guild.createdAt?.getTime?.() || null, createdAt: guild.createdAt?.getTime?.() || null,
owner: owner ? { id: owner.id, tag: owner.user?.tag } : null, owner: owner ? { id: owner.id, tag: owner.user?.tag } : null,
textCount, textCount,
@@ -92,6 +93,15 @@ router.get('/guild/info', requireAuth, async (req, res) => {
}); });
}); });
router.get('/growth', requireAuth, async (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const guild = context.client?.guilds.cache.get(guildId) || (await context.client?.guilds.fetch(guildId).catch(() => null));
if (!guild) return res.status(404).json({ error: 'guild not found' });
const stats = await context.growth.getStats(guild);
res.json({ stats });
});
router.get('/guild/activity', requireAuth, (req, res) => { router.get('/guild/activity', requireAuth, (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined; const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' }); if (!guildId) return res.status(400).json({ error: 'guildId required' });
@@ -151,9 +161,22 @@ router.get('/overview', requireAuth, async (req, res) => {
} }
}); });
router.get('/admin/overview', requireAuth, requireAdmin, (_req, res) => { router.get('/admin/overview', requireAuth, requireAdmin, async (_req, res) => {
const overview = context.admin.getOverview(); const overview = context.admin.getOverview();
res.json({ overview }); const guildList = Array.from(context.client?.guilds.cache.values() || []).map((g) => ({
id: g.id,
name: g.name,
icon: g.icon,
memberCount: g.memberCount,
boostCount: g.premiumSubscriptionCount ?? 0
}));
const [badgesAwarded, openTasks, activeWatchlist, pendingPartners] = await Promise.all([
prisma.userBadge.count(),
prisma.staffTask.count({ where: { status: { not: 'done' } } }),
prisma.watchlist.count({ where: { active: true } }),
prisma.partnerRequest.count({ where: { status: 'pending' } })
]);
res.json({ overview, guildList, usage: { badgesAwarded, openTasks, activeWatchlist, pendingPartners } });
}); });
router.get('/admin/activity', requireAuth, requireAdmin, (_req, res) => { router.get('/admin/activity', requireAuth, requireAdmin, (_req, res) => {
@@ -856,6 +879,21 @@ router.delete('/automod/strikes', requireAuth, async (req, res) => {
res.json({ ok: true }); res.json({ ok: true });
}); });
router.get('/watchlist', requireAuth, async (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const entries = await context.watchlist.list(guildId);
res.json({ entries });
});
router.delete('/watchlist', requireAuth, async (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
const userId = typeof req.query.userId === 'string' ? req.query.userId : undefined;
if (!guildId || !userId) return res.status(400).json({ error: 'guildId and userId required' });
await context.watchlist.remove(guildId, userId);
res.json({ ok: true });
});
router.get('/tasks', requireAuth, async (req, res) => { router.get('/tasks', requireAuth, async (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined; const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
const status = typeof req.query.status === 'string' ? req.query.status : undefined; const status = typeof req.query.status === 'string' ? req.query.status : undefined;