add badges, weekly plans, polls, suggestions, mod-case system, branding, partner system, alt-account detection, gallery, growth tracking
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:
@@ -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:
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
100
frontend/src/pages/Branding.tsx
Normal file
100
frontend/src/pages/Branding.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
frontend/src/pages/Growth.tsx
Normal file
76
frontend/src/pages/Growth.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
frontend/src/pages/Watchlist.tsx
Normal file
36
frontend/src/pages/Watchlist.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
165
node_modules/.prisma/client/edge.js
generated
vendored
File diff suppressed because one or more lines are too long
159
node_modules/.prisma/client/index-browser.js
generated
vendored
159
node_modules/.prisma/client/index-browser.js
generated
vendored
@@ -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
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
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
159
node_modules/.prisma/client/wasm.js
generated
vendored
@@ -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'
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
76
src/commands/admin/case.ts
Normal file
76
src/commands/admin/case.ts
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
27
src/commands/admin/warn.ts
Normal file
27
src/commands/admin/warn.ts
Normal 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;
|
||||
61
src/commands/admin/watch.ts
Normal file
61
src/commands/admin/watch.ts
Normal 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;
|
||||
17
src/commands/utility/announcement.ts
Normal file
17
src/commands/utility/announcement.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ChatInputCommandInteraction, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types';
|
||||
import { context } from '../../config/context';
|
||||
|
||||
const command: SlashCommand = {
|
||||
guildOnly: true,
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('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;
|
||||
63
src/commands/utility/badges.ts
Normal file
63
src/commands/utility/badges.ts
Normal 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;
|
||||
71
src/commands/utility/branding.ts
Normal file
71
src/commands/utility/branding.ts
Normal 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;
|
||||
56
src/commands/utility/gallery.ts
Normal file
56
src/commands/utility/gallery.ts
Normal 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;
|
||||
63
src/commands/utility/partner.ts
Normal file
63
src/commands/utility/partner.ts
Normal 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;
|
||||
32
src/commands/utility/poll.ts
Normal file
32
src/commands/utility/poll.ts
Normal 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;
|
||||
41
src/commands/utility/profile.ts
Normal file
41
src/commands/utility/profile.ts
Normal 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;
|
||||
47
src/commands/utility/rules.ts
Normal file
47
src/commands/utility/rules.ts
Normal 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;
|
||||
17
src/commands/utility/suggest.ts
Normal file
17
src/commands/utility/suggest.ts
Normal 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;
|
||||
23
src/commands/utility/weekplan.ts
Normal file
23
src/commands/utility/weekplan.ts
Normal 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;
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const event: EventHandler = {
|
||||
if (context.commandHandler) {
|
||||
await context.commandHandler.registerGuildCommands(guild.id);
|
||||
}
|
||||
context.growth.cacheInvites(guild).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
70
src/services/altAccountService.ts
Normal file
70
src/services/altAccountService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
70
src/services/badgeService.ts
Normal file
70
src/services/badgeService.ts
Normal 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 } } });
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
21
src/services/brandingService.ts
Normal file
21
src/services/brandingService.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
125
src/services/galleryService.ts
Normal file
125
src/services/galleryService.ts
Normal 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] };
|
||||
}
|
||||
}
|
||||
96
src/services/growthService.ts
Normal file
96
src/services/growthService.ts
Normal 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))
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
26
src/services/modCaseService.ts
Normal file
26
src/services/modCaseService.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
149
src/services/partnerService.ts
Normal file
149
src/services/partnerService.ts
Normal 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
156
src/services/pollService.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
41
src/services/rulesService.ts
Normal file
41
src/services/rulesService.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
126
src/services/suggestionService.ts
Normal file
126
src/services/suggestionService.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
48
src/services/watchlistService.ts
Normal file
48
src/services/watchlistService.ts
Normal 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 }));
|
||||
}
|
||||
}
|
||||
123
src/services/weeklyPlanService.ts
Normal file
123
src/services/weeklyPlanService.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user