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
env_file:
- .env
command: sh -c "npm run dev"
command: sh -c "npx prisma migrate deploy --schema=src/database/schema.prisma && npm run dev"
ports:
- "3000:3000"
depends_on:

View File

@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { useApp } from './context/AppContext';
import { AppLayout } from './components/layout/AppLayout';
import { GuildSelect } from './pages/GuildSelect';
@@ -17,10 +18,27 @@ import { SettingsPage } from './pages/Settings';
import { ModulesPage } from './pages/Modules';
import { Events } from './pages/Events';
import { Tasks } from './pages/Tasks';
import { Watchlist } from './pages/Watchlist';
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() {
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) {
return <GuildSelect />;
@@ -47,6 +65,9 @@ function AppContent() {
case 'modules': return <ModulesPage />;
case 'events': return <Events />;
case 'tasks': return <Tasks />;
case 'watchlist': return <Watchlist />;
case 'branding': return <Branding />;
case 'growth': return <Growth />;
case 'admin': return <Admin />;
default: return <Dashboard />;
}

View File

@@ -5,8 +5,8 @@ import {
} from '@heroui/react';
import {
LogOut, PanelLeftClose, PanelLeft, Activity, AudioLines, CalendarDays,
ClipboardList, Home, LogIn, ListChecks, Music, Puzzle, RadioTower, Settings,
Shield, Sparkles, Tag, Ticket, Wrench
ClipboardList, Eye, Home, LogIn, ListChecks, Music, Palette, Puzzle, RadioTower, Settings,
Shield, Sparkles, Tag, Ticket, TrendingUp, Wrench
} from 'lucide-react';
import { useApp } from '../../context/AppContext';
import { AppAvatar } from '../shared/AppAvatar';
@@ -40,6 +40,7 @@ const navGroups = [
{ key: 'automod', label: 'Automod', icon: <Shield size={18} /> },
{ key: 'reactionroles', label: 'Reaction Roles', icon: <Tag size={18} /> },
{ key: 'tasks', label: 'Team-Aufgaben', icon: <ListChecks size={18} /> },
{ key: 'watchlist', label: 'Watchlist', icon: <Eye size={18} /> },
]
},
{
@@ -49,29 +50,39 @@ const navGroups = [
{ key: 'music', label: 'Musik', icon: <Music size={18} /> },
{ key: 'statuspage', label: 'Statuspage', icon: <RadioTower size={18} /> },
{ key: 'serverstats', label: 'Server Stats', icon: <Activity size={18} /> },
{ key: 'growth', label: 'Wachstum', icon: <TrendingUp size={18} /> },
]
},
{
label: 'System',
items: [
{ key: 'modules', label: 'Module', icon: <Puzzle size={18} /> },
{ key: 'branding', label: 'Branding', icon: <Palette size={18} /> },
{ key: 'settings', label: 'Einstellungen', icon: <Settings size={18} /> },
]
}
];
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 branding = settings.brandingConfig || {};
const botName = branding.botName || 'Papo';
return (
<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 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 && (
<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>
)}

View File

@@ -4,7 +4,7 @@ import type {
AppConfig, User, Guild, NavKey, TicketRecord, StatusService,
EventItem, ReactionRoleSet, ModuleItem, LogEntry, SettingsState,
SupportLoginConfig, SupportLoginStatus, RegisterForm, RegisterFormField,
RegisterApplication, MusicSession, StaffTask
RegisterApplication, MusicSession, StaffTask, WatchlistEntry, GrowthStats
} from '../types';
const appConfig: AppConfig = (window as any).__PAPO__ || {};
@@ -46,6 +46,8 @@ type AppState = {
noteDraft: string;
tasks: StaffTask[];
taskDraft: { title: string; description: string };
watchlistEntries: WatchlistEntry[];
growthStats: GrowthStats | null;
};
type AppContextType = AppState & {
@@ -126,6 +128,9 @@ type AppContextType = AppState & {
createTask: () => Promise<void>;
updateTaskStatus: (id: string, status: 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);
@@ -183,6 +188,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
const [noteDraft, setNoteDraft] = useState('');
const [tasks, setTasks] = useState<StaffTask[]>([]);
const [taskDraft, setTaskDraft] = useState({ title: '', description: '' });
const [watchlistEntries, setWatchlistEntries] = useState<WatchlistEntry[]>([]);
const [growthStats, setGrowthStats] = useState<GrowthStats | null>(null);
const setSection = useCallback((key: NavKey) => {
setSectionState(key);
@@ -233,7 +240,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
try {
const [guildInfoRes, overviewRes, activityRes, logsRes, settingsRes, modulesRes,
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>(`/overview?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>(`/register/forms?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);
setOverview(overviewRes);
@@ -268,6 +276,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
setRegisterForms(registerFormsRes.forms || []);
setRegisterApps(registerAppsRes.applications || []);
setTasks(tasksRes.tasks || []);
setWatchlistEntries(watchlistRes.entries || []);
setReactionDraft({ title: '', channelId: '', entries: '' });
await Promise.all([loadTicketData(guildId), loadAdminData()]);
setStatusMessage('');
@@ -282,7 +291,13 @@ export function AppProvider({ children }: { children: ReactNode }) {
apiFetch<any>('/admin/activity'),
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 {}
}
@@ -573,6 +588,24 @@ export function AppProvider({ children }: { children: ReactNode }) {
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(() => {
window.location.href = `${appConfig.baseAuth || '/auth'}/logout`;
}, []);
@@ -587,7 +620,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
formDraft, editingFormId, registerTab, statusServiceDraft, statsItemDraft,
ticketDetail, ticketMessages, kbEditDraft, automationEditDraft,
automodStrikes, registerStatusFilter, registerFormFilter, selectedAppId,
appNotes, appHistory, noteDraft, tasks, taskDraft,
appNotes, appHistory, noteDraft, tasks, taskDraft, watchlistEntries, growthStats,
setCurrentGuildId, setSection, setSettings, setBirthday, setSupportLogin,
setStatusDraft, setStatsDraft, setStatusMessage, loadGuildData,
saveSettingsPayload, saveBirthday, saveStatuspage, saveServerStats,
@@ -603,6 +636,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
loadAutomodStrikes, resetAutomodStrike, setRegisterStatusFilter, setRegisterFormFilter,
loadRegisterApps, openAppDetail, setNoteDraft, addAppNote,
setTaskDraft, createTask, updateTaskStatus, deleteTask,
loadWatchlist, removeFromWatchlist, loadGrowthStats,
}}>
{children}
</AppContext.Provider>

View File

@@ -1,10 +1,11 @@
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 { SectionCard } from '../components/shared/SectionCard';
import { StatCard } from '../components/shared/StatCard';
import { AppAvatar } from '../components/shared/AppAvatar';
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'> = {
INFO: 'accent',
@@ -20,6 +21,8 @@ export function Admin() {
const overview = admin.overview || {};
const activityPoints: { hour: string; count: number }[] = admin.activity || [];
const logs: { timestamp: number; level: string; message: string; guildId?: string; category?: string }[] = admin.logs || [];
const guildList: { id: string; name: string; icon?: string; memberCount: number; boostCount: number }[] = admin.guildList || [];
const usage = admin.usage || {};
const chartItems = activityPoints.slice(-12).map((p) => ({
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} />
</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">
<Card>
<CardHeader className="px-5 pt-5 pb-0">
@@ -54,33 +65,61 @@ export function Admin() {
<Card>
<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
<h3 className="text-base font-semibold">Server</h3>
<Chip size="sm" variant="soft" color="accent">
{guildList.length} Guilds
</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" />
{guildList.length ? guildList.map((g) => (
<div key={g.id} className="bg-surface-tertiary flex items-center gap-3 rounded-xl px-4 py-3 text-sm">
<AppAvatar size="sm" src={guildIconUrl(g as any)} name={g.name} />
<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 className="truncate font-medium">{g.name}</div>
<div className="flex items-center gap-3 text-xs text-muted">
<span className="flex items-center gap-1"><Users size={12} /> {g.memberCount}</span>
{g.boostCount > 0 && <span>💎 {g.boostCount}</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
<Server size={20} />
Keine Server
</div>
)}
</CardContent>
</Card>
</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>
);
}

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'
| 'events'
| 'tasks'
| 'watchlist'
| 'branding'
| 'growth'
| 'admin';
export type TicketRecord = {
@@ -155,6 +158,27 @@ export type StaffTask = {
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 = {
guildId: string;
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',
lockdownConfig: 'lockdownConfig',
tasksEnabled: 'tasksEnabled',
badgesEnabled: 'badgesEnabled',
brandingConfig: 'brandingConfig',
partnerConfig: 'partnerConfig',
galleryConfig: 'galleryConfig',
supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt',
createdAt: 'createdAt'
@@ -329,6 +333,145 @@ exports.Prisma.StaffTaskScalarFieldEnum = {
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 = {
asc: 'asc',
desc: 'desc'
@@ -377,7 +520,21 @@ exports.Prisma.ModelName = {
RegisterApplicationAnswer: 'RegisterApplicationAnswer',
RegisterApplicationNote: 'RegisterApplicationNote',
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',
lockdownConfig: 'lockdownConfig',
tasksEnabled: 'tasksEnabled',
badgesEnabled: 'badgesEnabled',
brandingConfig: 'brandingConfig',
partnerConfig: 'partnerConfig',
galleryConfig: 'galleryConfig',
supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt',
createdAt: 'createdAt'
@@ -329,6 +333,145 @@ exports.Prisma.StaffTaskScalarFieldEnum = {
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 = {
asc: 'asc',
desc: 'desc'
@@ -377,7 +520,21 @@ exports.Prisma.ModelName = {
RegisterApplicationAnswer: 'RegisterApplicationAnswer',
RegisterApplicationNote: 'RegisterApplicationNote',
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 interaction.reply({ content: `${user.tag} wurde gebannt. Grund: ${reason}` });
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 interaction.reply({ content: `${user.tag} wurde gekickt.` });
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 interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gemutet.` });
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 interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gebannt.` });
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 () => {
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 interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten in Timeout gesetzt.` });
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 { SetupWizardService } from '../services/setupService';
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 moduleService = new BotModuleService();
const ticketService = new TicketService();
const birthdayService = new BirthdayService();
const eventService = new EventService();
const badgeService = new BadgeService();
export const context = {
client: null as Client | null,
@@ -37,9 +52,9 @@ export const context = {
modules: moduleService,
admin: new AdminService(),
statuspage: new StatuspageService(),
birthdays: new BirthdayService(),
birthdays: birthdayService,
reactionRoles: new ReactionRoleService(),
events: new EventService(),
events: eventService,
ticketAutomation: new TicketAutomationService(),
knowledgeBase: new KnowledgeBaseService(),
register: new RegisterService(),
@@ -47,9 +62,24 @@ export const context = {
lockdown: new LockdownService(),
tasks: new TaskService(),
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({
musicEnabled: {
onDisable: async () => context.music.stopAll()

View File

@@ -78,6 +78,7 @@ export interface GuildSettings {
supportRoleId?: string;
welcomeEnabled?: boolean;
tasksEnabled?: boolean;
badgesEnabled?: boolean;
lockdownConfig?: {
active?: boolean;
activatedAt?: string;
@@ -86,6 +87,22 @@ export interface GuildSettings {
staffRoleId?: string;
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 {
@@ -105,7 +122,8 @@ class SettingsStore {
'reactionRolesEnabled',
'eventsEnabled',
'registerEnabled',
'tasksEnabled'
'tasksEnabled',
'badgesEnabled'
] as const;
defaultOn.forEach((key) => {
if (normalized[key] === undefined) normalized[key] = true;
@@ -148,6 +166,10 @@ class SettingsStore {
serverStatsConfig: (row as any).serverStatsConfig ?? undefined,
lockdownConfig: (row as any).lockdownConfig ?? 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
} satisfies GuildSettings;
this.cache.set(row.guildId, this.applyModuleDefaults(cfg));
@@ -206,6 +228,15 @@ class SettingsStore {
if (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.statuspageEnabled = mergedAutomod.statuspageEnabled;
merged.statuspageConfig = mergedAutomod.statuspageConfig;
@@ -237,6 +268,10 @@ class SettingsStore {
serverStatsConfig: (merged as any).serverStatsConfig ?? null,
lockdownConfig: merged.lockdownConfig ?? Prisma.JsonNull,
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
},
create: {
@@ -263,6 +298,10 @@ class SettingsStore {
serverStatsConfig: (merged as any).serverStatsConfig ?? null,
lockdownConfig: merged.lockdownConfig ?? Prisma.JsonNull,
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
}
});

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?
lockdownConfig Json?
tasksEnabled Boolean?
badgesEnabled Boolean?
brandingConfig Json?
partnerConfig Json?
galleryConfig Json?
supportRoleId String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
@@ -259,3 +263,186 @@ model StaffTask {
@@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) {
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);
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 welcomeCfg = guildConfig?.welcomeConfig || guildConfig?.automodConfig?.welcomeConfig;
if (welcomeCfg?.enabled && welcomeCfg.channelId) {

View File

@@ -7,6 +7,7 @@ const event: EventHandler = {
execute(member: GuildMember) {
context.logging.logMemberLeave(member);
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',
execute(oldMember: GuildMember | PartialGuildMember, newMember: GuildMember) {
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 after = new Set(newMember.roles.cache.keys());
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 { context } from '../config/context';
import { buildTaskCard } from '../commands/utility/task';
import { handleBrandingModal } from '../commands/utility/branding';
const event: EventHandler = {
name: 'interactionCreate',
@@ -13,7 +14,7 @@ const event: EventHandler = {
return;
}
if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isChannelSelectMenu()) {
if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isChannelSelectMenu() || interaction.isRoleSelectMenu()) {
if (interaction.customId.startsWith('setup:')) {
await context.setup.handleComponent(interaction as any);
return;
@@ -28,6 +29,26 @@ const event: EventHandler = {
await interaction.update(buildTaskCard(task));
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()) {
@@ -54,6 +75,18 @@ const event: EventHandler = {
await context.embedBuilder.handleModal(interaction);
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 (cfg?.automodEnabled === true) context.automod.checkMessage(message, cfg);
if (cfg?.levelingEnabled === true) context.leveling.handleMessage(message);
if (cfg?.badgesEnabled !== false) context.badges.trackMessage(message);
// Ticket SLA + KB
await context.tickets.trackFirstResponse(message);
await context.tickets.suggestKnowledgeBase(message);

View File

@@ -6,6 +6,9 @@ const event: EventHandler = {
name: 'messageDelete',
execute(message: 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) {
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) {
logger.warn(`Ready handler failed: ${err}`);
}

View File

@@ -10,6 +10,7 @@ import { settingsStore } from './config/state';
async function bootstrap() {
context.admin.setStartTime(Date.now());
await settingsStore.init();
await context.watchlist.loadAll();
const client = new Client({
intents: [
@@ -36,6 +37,8 @@ async function bootstrap() {
context.events.startScheduler();
context.register.setClient(client);
context.stats.setClient(client);
context.polls.setClient(client);
context.polls.startScheduler();
await context.reactionRoles.loadCache();
logger.setSink((entry) => context.admin.pushLog(entry));
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 { LoggingService } from './loggingService';
import { prisma } from '../database';
import { context } from '../config/context';
export type AutomodFilterKey =
| 'linkFilter'
@@ -284,6 +285,7 @@ export class AutoModService {
await prisma.automodStrike.create({
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 since = decayHours > 0 ? new Date(Date.now() - decayHours * 60 * 60 * 1000) : undefined;
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 { settingsStore } from '../config/state';
import { logger } from '../utils/logger';
import { BadgeService } from './badgeService';
export function normalizeBirthdayInput(raw: string) {
const input = (raw || '').trim();
@@ -60,11 +61,16 @@ export class BirthdayService {
private client: Client | null = null;
private timer: NodeJS.Timeout | null = null;
private lastSent = new Map<string, string>();
private badges: BadgeService | null = null;
public setClient(client: Client) {
this.client = client;
}
public setBadgeService(badges: BadgeService) {
this.badges = badges;
}
public startScheduler(intervalMs = 60 * 60 * 1000) {
if (this.timer) clearInterval(this.timer);
const interval = Math.max(10 * 60 * 1000, intervalMs);
@@ -82,6 +88,7 @@ export class BirthdayService {
update: { birthDate },
create: { guildId, userId, birthDate }
});
await this.badges?.award(guildId, userId, 'birthday_set');
}
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',
events: 'eventsEnabled',
// Tasks
task: 'tasksEnabled'
task: 'tasksEnabled',
// Badges
badges: 'badgesEnabled',
profile: 'badgesEnabled'
};
constructor(private client: Client, private admin?: AdminService, private statuspage?: StatuspageService) {}

View File

@@ -10,11 +10,14 @@ import {
EmbedBuilder,
ModalBuilder,
ModalSubmitInteraction,
RoleSelectMenuBuilder,
RoleSelectMenuInteraction,
TextInputBuilder,
TextInputStyle
} from 'discord.js';
import { context } from '../config/context';
type ComponentInteraction = ButtonInteraction | ChannelSelectMenuInteraction;
type ComponentInteraction = ButtonInteraction | ChannelSelectMenuInteraction | RoleSelectMenuInteraction;
interface EmbedDraft {
guildId: string;
@@ -26,6 +29,7 @@ interface EmbedDraft {
buttonLabel?: string;
buttonUrl?: string;
channelId?: string;
pingRoleId?: string;
}
export class EmbedBuilderService {
@@ -94,6 +98,12 @@ export class EmbedBuilderService {
return;
}
if (interaction.isRoleSelectMenu()) {
draft.pingRoleId = interaction.values[0];
await interaction.update(this.renderPreview(draft));
return;
}
const action = interaction.customId.split(':')[1];
if (action === 'addbutton') {
@@ -127,7 +137,12 @@ export class EmbedBuilderService {
draft.buttonLabel && draft.buttonUrl
? [new ActionRowBuilder<ButtonBuilder>().addComponents(new ButtonBuilder().setLabel(draft.buttonLabel).setURL(draft.buttonUrl).setStyle(ButtonStyle.Link))]
: [];
await channel.send({ embeds: [embed], components });
await channel.send({
content: draft.pingRoleId ? `<@&${draft.pingRoleId}>` : undefined,
embeds: [embed],
components,
allowedMentions: draft.pingRoleId ? { roles: [draft.pingRoleId] } : undefined
});
this.drafts.delete(messageId);
await interaction.update({ content: `Embed gesendet in <#${draft.channelId}>.`, embeds: [], components: [] });
return;
@@ -139,16 +154,19 @@ export class EmbedBuilderService {
const channelRow = new ActionRowBuilder<ChannelSelectMenuBuilder>().addComponents(
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(
new ButtonBuilder().setCustomId('embed:addbutton').setLabel(draft.buttonLabel ? 'Link-Button bearbeiten' : 'Link-Button hinzufügen').setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId('embed:send').setLabel('Senden').setStyle(ButtonStyle.Success).setDisabled(!draft.channelId),
new ButtonBuilder().setCustomId('embed:cancel').setLabel('Abbrechen').setStyle(ButtonStyle.Danger)
);
return { embeds: [embed], components: [channelRow, buttonRow] };
return { embeds: [embed], components: [channelRow, roleRow, buttonRow] };
}
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.description) embed.setDescription(draft.description);
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 { settingsStore } from '../config/state';
import { logger } from '../utils/logger';
import { BadgeService } from './badgeService';
export type RepeatType = 'none' | 'daily' | 'weekly' | 'monthly';
export class EventService {
private client: Client | null = null;
private timer: NodeJS.Timeout | null = null;
private badges: BadgeService | null = null;
public setClient(client: Client) {
this.client = client;
}
public setBadgeService(badges: BadgeService) {
this.badges = badges;
}
public startScheduler(intervalMs = 60000) {
if (this.timer) clearInterval(this.timer);
const interval = Math.max(30000, intervalMs);
@@ -135,6 +141,7 @@ export class EventService {
update: { canceledAt: null },
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 });
} else {
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 { settingsStore } from '../config/state';
import type { AdminService } from './adminService';
import { context } from '../config/context';
let adminSink: AdminService | null = null;
export const setLoggingAdmin = (admin: AdminService) => {
@@ -66,6 +67,7 @@ export class LoggingService {
.setDescription(`${member.user.tag} ist dem Server beigetreten.`)
.setColor(0x00ff99)
.setTimestamp();
context.branding.applyFooter(embed, member.guild.id);
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' });
}
@@ -79,6 +81,7 @@ export class LoggingService {
.setDescription(`${member.user.tag} hat den Server verlassen.`)
.setColor(0xff9900)
.setTimestamp();
context.branding.applyFooter(embed, member.guild.id);
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' });
}
@@ -94,6 +97,7 @@ export class LoggingService {
.addFields({ name: 'Inhalt', value: this.safeField(message.content) })
.setColor(0xff0000)
.setTimestamp();
context.branding.applyFooter(embed, message.guild.id);
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log message delete', err));
adminSink?.pushGuildLog({
guildId: message.guild.id,
@@ -118,6 +122,7 @@ export class LoggingService {
)
.setColor(0xffff00)
.setTimestamp();
context.branding.applyFooter(embed, oldMessage.guild.id);
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log message edit', err));
adminSink?.pushGuildLog({
guildId: oldMessage.guild.id,
@@ -141,6 +146,7 @@ export class LoggingService {
.addFields({ name: 'Grund', value: this.safeField(reason || 'Nicht angegeben') })
.setColor(0x7289da)
.setTimestamp();
context.branding.applyFooter(embed, resolvedGuild.id);
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log action', err));
const guildId = resolvedGuild.id;
if (guildId) {
@@ -174,6 +180,7 @@ export class LoggingService {
if (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));
adminSink?.pushGuildLog({
guildId: guild.id,
@@ -185,6 +192,55 @@ export class LoggingService {
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[]) {
const guildId = member.guild.id;
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'
| 'registerEnabled'
| 'serverStatsEnabled'
| 'tasksEnabled';
| 'tasksEnabled'
| 'badgesEnabled';
export interface GuildModuleState {
key: ModuleKey;
@@ -35,7 +36,8 @@ const MODULES: Record<ModuleKey, { name: string; description: string }> = {
eventsEnabled: { name: 'Termine', description: 'Events planen, erinnern und Anmeldungen sammeln.' },
registerEnabled: { name: 'Register', description: 'Registrierungsformulare und Bewerbungen.' },
serverStatsEnabled: { name: 'Server Stats', description: 'Zeigt Member-/Channel-Zahlen als Voice-Statistiken an.' },
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 {

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 { settingsStore } from '../config/state';
import { env } from '../config/env';
import { context } from '../config/context';
export class RegisterService {
private client: Client | null = null;
@@ -103,7 +104,8 @@ export class RegisterService {
const embed = new EmbedBuilder()
.setTitle(form.name)
.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 row = new ActionRowBuilder<ButtonBuilder>().addComponents(btn);
const sent = await (channel as any).send({ embeds: [embed], components: [row] });
@@ -205,7 +207,7 @@ export class RegisterService {
const embed = new EmbedBuilder()
.setTitle(`Registrierung: ${form.name}`)
.setDescription(form.description || '')
.setColor(0xf97316)
.setColor(context.branding.getColor(guildId))
.addFields(
...fields.map((f) => ({
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: 'support', label: 'Support', presetModules: ['ticketsEnabled', 'statuspageEnabled', 'automodEnabled'] },
{ 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;
@@ -244,15 +246,21 @@ export class SetupWizardService {
if (state.step === 0) {
embed.setTitle('Setup Server-Art').setDescription('Wähle die Art deines Servers, um passende Module vorzuschlagen.');
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
...SERVER_TYPES.map((t) =>
new ButtonBuilder()
.setCustomId(`setup:type:${t.key}`)
.setLabel(t.label)
.setStyle(state.serverType === t.key ? ButtonStyle.Primary : ButtonStyle.Secondary)
)
);
return { embeds: [embed], components: [row, cancelRow] };
const typeRows: ActionRowBuilder<ButtonBuilder>[] = [];
for (let i = 0; i < SERVER_TYPES.length; i += 5) {
const chunk = SERVER_TYPES.slice(i, i + 5);
typeRows.push(
new ActionRowBuilder<ButtonBuilder>().addComponents(
...chunk.map((t) =>
new ButtonBuilder()
.setCustomId(`setup:type:${t.key}`)
.setLabel(t.label)
.setStyle(state.serverType === t.key ? ButtonStyle.Primary : ButtonStyle.Secondary)
)
)
);
}
return { embeds: [embed], components: [...typeRows, cancelRow] };
}
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()
.setTitle(`Ticket: ${topic}`)
.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(
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.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;
}

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,
icon: guild.icon,
memberCount: guild.memberCount,
boostCount: guild.premiumSubscriptionCount ?? 0,
createdAt: guild.createdAt?.getTime?.() || null,
owner: owner ? { id: owner.id, tag: owner.user?.tag } : null,
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) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
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();
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) => {
@@ -856,6 +879,21 @@ router.delete('/automod/strikes', requireAuth, async (req, res) => {
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) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
const status = typeof req.query.status === 'string' ? req.query.status : undefined;