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
|
working_dir: /usr/src/app
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
command: sh -c "npm run dev"
|
command: sh -c "npx prisma migrate deploy --schema=src/database/schema.prisma && npm run dev"
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
import { useApp } from './context/AppContext';
|
import { useApp } from './context/AppContext';
|
||||||
import { AppLayout } from './components/layout/AppLayout';
|
import { AppLayout } from './components/layout/AppLayout';
|
||||||
import { GuildSelect } from './pages/GuildSelect';
|
import { GuildSelect } from './pages/GuildSelect';
|
||||||
@@ -17,10 +18,27 @@ import { SettingsPage } from './pages/Settings';
|
|||||||
import { ModulesPage } from './pages/Modules';
|
import { ModulesPage } from './pages/Modules';
|
||||||
import { Events } from './pages/Events';
|
import { Events } from './pages/Events';
|
||||||
import { Tasks } from './pages/Tasks';
|
import { Tasks } from './pages/Tasks';
|
||||||
|
import { Watchlist } from './pages/Watchlist';
|
||||||
import { Admin } from './pages/Admin';
|
import { Admin } from './pages/Admin';
|
||||||
|
import { Branding } from './pages/Branding';
|
||||||
|
import { Growth } from './pages/Growth';
|
||||||
|
|
||||||
|
const THEME_COLORS: Record<string, string> = {
|
||||||
|
orange: '#f97316',
|
||||||
|
blue: '#3b82f6',
|
||||||
|
green: '#22c55e',
|
||||||
|
purple: '#a855f7',
|
||||||
|
red: '#ef4444'
|
||||||
|
};
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const { guilds, currentGuildId, section } = useApp();
|
const { guilds, currentGuildId, section, settings } = useApp();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const branding = settings.brandingConfig || {};
|
||||||
|
const color = branding.embedColor || (branding.theme ? THEME_COLORS[branding.theme] : undefined);
|
||||||
|
if (color) document.documentElement.style.setProperty('--accent', color);
|
||||||
|
}, [settings.brandingConfig]);
|
||||||
|
|
||||||
if (!guilds.length) {
|
if (!guilds.length) {
|
||||||
return <GuildSelect />;
|
return <GuildSelect />;
|
||||||
@@ -47,6 +65,9 @@ function AppContent() {
|
|||||||
case 'modules': return <ModulesPage />;
|
case 'modules': return <ModulesPage />;
|
||||||
case 'events': return <Events />;
|
case 'events': return <Events />;
|
||||||
case 'tasks': return <Tasks />;
|
case 'tasks': return <Tasks />;
|
||||||
|
case 'watchlist': return <Watchlist />;
|
||||||
|
case 'branding': return <Branding />;
|
||||||
|
case 'growth': return <Growth />;
|
||||||
case 'admin': return <Admin />;
|
case 'admin': return <Admin />;
|
||||||
default: return <Dashboard />;
|
default: return <Dashboard />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
} from '@heroui/react';
|
} from '@heroui/react';
|
||||||
import {
|
import {
|
||||||
LogOut, PanelLeftClose, PanelLeft, Activity, AudioLines, CalendarDays,
|
LogOut, PanelLeftClose, PanelLeft, Activity, AudioLines, CalendarDays,
|
||||||
ClipboardList, Home, LogIn, ListChecks, Music, Puzzle, RadioTower, Settings,
|
ClipboardList, Eye, Home, LogIn, ListChecks, Music, Palette, Puzzle, RadioTower, Settings,
|
||||||
Shield, Sparkles, Tag, Ticket, Wrench
|
Shield, Sparkles, Tag, Ticket, TrendingUp, Wrench
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useApp } from '../../context/AppContext';
|
import { useApp } from '../../context/AppContext';
|
||||||
import { AppAvatar } from '../shared/AppAvatar';
|
import { AppAvatar } from '../shared/AppAvatar';
|
||||||
@@ -40,6 +40,7 @@ const navGroups = [
|
|||||||
{ key: 'automod', label: 'Automod', icon: <Shield size={18} /> },
|
{ key: 'automod', label: 'Automod', icon: <Shield size={18} /> },
|
||||||
{ key: 'reactionroles', label: 'Reaction Roles', icon: <Tag size={18} /> },
|
{ key: 'reactionroles', label: 'Reaction Roles', icon: <Tag size={18} /> },
|
||||||
{ key: 'tasks', label: 'Team-Aufgaben', icon: <ListChecks size={18} /> },
|
{ key: 'tasks', label: 'Team-Aufgaben', icon: <ListChecks size={18} /> },
|
||||||
|
{ key: 'watchlist', label: 'Watchlist', icon: <Eye size={18} /> },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -49,29 +50,39 @@ const navGroups = [
|
|||||||
{ key: 'music', label: 'Musik', icon: <Music size={18} /> },
|
{ key: 'music', label: 'Musik', icon: <Music size={18} /> },
|
||||||
{ key: 'statuspage', label: 'Statuspage', icon: <RadioTower size={18} /> },
|
{ key: 'statuspage', label: 'Statuspage', icon: <RadioTower size={18} /> },
|
||||||
{ key: 'serverstats', label: 'Server Stats', icon: <Activity size={18} /> },
|
{ key: 'serverstats', label: 'Server Stats', icon: <Activity size={18} /> },
|
||||||
|
{ key: 'growth', label: 'Wachstum', icon: <TrendingUp size={18} /> },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'System',
|
label: 'System',
|
||||||
items: [
|
items: [
|
||||||
{ key: 'modules', label: 'Module', icon: <Puzzle size={18} /> },
|
{ key: 'modules', label: 'Module', icon: <Puzzle size={18} /> },
|
||||||
|
{ key: 'branding', label: 'Branding', icon: <Palette size={18} /> },
|
||||||
{ key: 'settings', label: 'Einstellungen', icon: <Settings size={18} /> },
|
{ key: 'settings', label: 'Einstellungen', icon: <Settings size={18} /> },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const { user, guilds, currentGuildId, section, setCurrentGuildId, setSection, handleLogout } = useApp();
|
const { user, guilds, currentGuildId, section, setCurrentGuildId, setSection, handleLogout, settings } = useApp();
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const branding = settings.brandingConfig || {};
|
||||||
|
const botName = branding.botName || 'Papo';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`bg-surface border-r border-border flex h-full flex-col transition-all duration-200 ${collapsed ? 'w-14' : 'w-48'}`}>
|
<aside className={`bg-surface border-r border-border flex h-full flex-col transition-all duration-200 ${collapsed ? 'w-14' : 'w-48'}`}>
|
||||||
<div className={`flex items-center gap-2 px-2.5 pt-3 pb-2.5 ${collapsed ? 'flex-col' : 'justify-between'}`}>
|
<div className={`flex items-center gap-2 px-2.5 pt-3 pb-2.5 ${collapsed ? 'flex-col' : 'justify-between'}`}>
|
||||||
<div className={`flex min-w-0 items-center gap-2 ${collapsed ? 'flex-col' : ''}`}>
|
<div className={`flex min-w-0 items-center gap-2 ${collapsed ? 'flex-col' : ''}`}>
|
||||||
<div className="bg-accent text-accent-foreground flex size-8 shrink-0 items-center justify-center rounded-xl text-sm font-black">P</div>
|
{branding.logoUrl ? (
|
||||||
|
<img src={branding.logoUrl} alt={botName} className="size-8 shrink-0 rounded-xl object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="bg-accent text-accent-foreground flex size-8 shrink-0 items-center justify-center rounded-xl text-sm font-black">
|
||||||
|
{botName.trim()[0]?.toUpperCase() || 'P'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-bold leading-tight">Papo</div>
|
<div className="truncate text-sm font-bold leading-tight">{botName}</div>
|
||||||
<div className="text-[9px] uppercase tracking-widest text-muted">Dashboard</div>
|
<div className="text-[9px] uppercase tracking-widest text-muted">Dashboard</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type {
|
|||||||
AppConfig, User, Guild, NavKey, TicketRecord, StatusService,
|
AppConfig, User, Guild, NavKey, TicketRecord, StatusService,
|
||||||
EventItem, ReactionRoleSet, ModuleItem, LogEntry, SettingsState,
|
EventItem, ReactionRoleSet, ModuleItem, LogEntry, SettingsState,
|
||||||
SupportLoginConfig, SupportLoginStatus, RegisterForm, RegisterFormField,
|
SupportLoginConfig, SupportLoginStatus, RegisterForm, RegisterFormField,
|
||||||
RegisterApplication, MusicSession, StaffTask
|
RegisterApplication, MusicSession, StaffTask, WatchlistEntry, GrowthStats
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
const appConfig: AppConfig = (window as any).__PAPO__ || {};
|
const appConfig: AppConfig = (window as any).__PAPO__ || {};
|
||||||
@@ -46,6 +46,8 @@ type AppState = {
|
|||||||
noteDraft: string;
|
noteDraft: string;
|
||||||
tasks: StaffTask[];
|
tasks: StaffTask[];
|
||||||
taskDraft: { title: string; description: string };
|
taskDraft: { title: string; description: string };
|
||||||
|
watchlistEntries: WatchlistEntry[];
|
||||||
|
growthStats: GrowthStats | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AppContextType = AppState & {
|
type AppContextType = AppState & {
|
||||||
@@ -126,6 +128,9 @@ type AppContextType = AppState & {
|
|||||||
createTask: () => Promise<void>;
|
createTask: () => Promise<void>;
|
||||||
updateTaskStatus: (id: string, status: string) => Promise<void>;
|
updateTaskStatus: (id: string, status: string) => Promise<void>;
|
||||||
deleteTask: (id: string) => Promise<void>;
|
deleteTask: (id: string) => Promise<void>;
|
||||||
|
loadWatchlist: () => Promise<void>;
|
||||||
|
removeFromWatchlist: (userId: string) => Promise<void>;
|
||||||
|
loadGrowthStats: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AppContext = createContext<AppContextType | null>(null);
|
const AppContext = createContext<AppContextType | null>(null);
|
||||||
@@ -183,6 +188,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
const [noteDraft, setNoteDraft] = useState('');
|
const [noteDraft, setNoteDraft] = useState('');
|
||||||
const [tasks, setTasks] = useState<StaffTask[]>([]);
|
const [tasks, setTasks] = useState<StaffTask[]>([]);
|
||||||
const [taskDraft, setTaskDraft] = useState({ title: '', description: '' });
|
const [taskDraft, setTaskDraft] = useState({ title: '', description: '' });
|
||||||
|
const [watchlistEntries, setWatchlistEntries] = useState<WatchlistEntry[]>([]);
|
||||||
|
const [growthStats, setGrowthStats] = useState<GrowthStats | null>(null);
|
||||||
|
|
||||||
const setSection = useCallback((key: NavKey) => {
|
const setSection = useCallback((key: NavKey) => {
|
||||||
setSectionState(key);
|
setSectionState(key);
|
||||||
@@ -233,7 +240,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
const [guildInfoRes, overviewRes, activityRes, logsRes, settingsRes, modulesRes,
|
const [guildInfoRes, overviewRes, activityRes, logsRes, settingsRes, modulesRes,
|
||||||
birthdayRes, reactionRes, statusRes, statsRes, eventsRes, supportLoginRes,
|
birthdayRes, reactionRes, statusRes, statsRes, eventsRes, supportLoginRes,
|
||||||
registerFormsRes, registerAppsRes, tasksRes] = await Promise.all([
|
registerFormsRes, registerAppsRes, tasksRes, watchlistRes] = await Promise.all([
|
||||||
apiFetch<any>(`/guild/info?guildId=${encodeURIComponent(guildId)}`),
|
apiFetch<any>(`/guild/info?guildId=${encodeURIComponent(guildId)}`),
|
||||||
apiFetch<any>(`/overview?guildId=${encodeURIComponent(guildId)}`),
|
apiFetch<any>(`/overview?guildId=${encodeURIComponent(guildId)}`),
|
||||||
apiFetch<any>(`/guild/activity?guildId=${encodeURIComponent(guildId)}`),
|
apiFetch<any>(`/guild/activity?guildId=${encodeURIComponent(guildId)}`),
|
||||||
@@ -248,7 +255,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
apiFetch<any>(`/tickets/support-login?guildId=${encodeURIComponent(guildId)}`),
|
apiFetch<any>(`/tickets/support-login?guildId=${encodeURIComponent(guildId)}`),
|
||||||
apiFetch<any>(`/register/forms?guildId=${encodeURIComponent(guildId)}`),
|
apiFetch<any>(`/register/forms?guildId=${encodeURIComponent(guildId)}`),
|
||||||
apiFetch<any>(`/register/apps?guildId=${encodeURIComponent(guildId)}`),
|
apiFetch<any>(`/register/apps?guildId=${encodeURIComponent(guildId)}`),
|
||||||
apiFetch<any>(`/tasks?guildId=${encodeURIComponent(guildId)}`)
|
apiFetch<any>(`/tasks?guildId=${encodeURIComponent(guildId)}`),
|
||||||
|
apiFetch<any>(`/watchlist?guildId=${encodeURIComponent(guildId)}`)
|
||||||
]);
|
]);
|
||||||
setGuildInfo(guildInfoRes.guild || null);
|
setGuildInfo(guildInfoRes.guild || null);
|
||||||
setOverview(overviewRes);
|
setOverview(overviewRes);
|
||||||
@@ -268,6 +276,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
setRegisterForms(registerFormsRes.forms || []);
|
setRegisterForms(registerFormsRes.forms || []);
|
||||||
setRegisterApps(registerAppsRes.applications || []);
|
setRegisterApps(registerAppsRes.applications || []);
|
||||||
setTasks(tasksRes.tasks || []);
|
setTasks(tasksRes.tasks || []);
|
||||||
|
setWatchlistEntries(watchlistRes.entries || []);
|
||||||
setReactionDraft({ title: '', channelId: '', entries: '' });
|
setReactionDraft({ title: '', channelId: '', entries: '' });
|
||||||
await Promise.all([loadTicketData(guildId), loadAdminData()]);
|
await Promise.all([loadTicketData(guildId), loadAdminData()]);
|
||||||
setStatusMessage('');
|
setStatusMessage('');
|
||||||
@@ -282,7 +291,13 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
apiFetch<any>('/admin/activity'),
|
apiFetch<any>('/admin/activity'),
|
||||||
apiFetch<any>('/admin/logs')
|
apiFetch<any>('/admin/logs')
|
||||||
]);
|
]);
|
||||||
setAdmin({ overview: overviewRes.overview || {}, activity: activityRes.points || [], logs: logsRes.logs || [] });
|
setAdmin({
|
||||||
|
overview: overviewRes.overview || {},
|
||||||
|
activity: activityRes.points || [],
|
||||||
|
logs: logsRes.logs || [],
|
||||||
|
guildList: overviewRes.guildList || [],
|
||||||
|
usage: overviewRes.usage || {}
|
||||||
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,6 +588,24 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
await loadGuildData(currentGuildId);
|
await loadGuildData(currentGuildId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadWatchlist() {
|
||||||
|
if (!currentGuildId) return;
|
||||||
|
const res = await apiFetch<any>(`/watchlist?guildId=${encodeURIComponent(currentGuildId)}`);
|
||||||
|
setWatchlistEntries(res.entries || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeFromWatchlist(userId: string) {
|
||||||
|
await apiFetch(`/watchlist?guildId=${encodeURIComponent(currentGuildId)}&userId=${encodeURIComponent(userId)}`, { method: 'DELETE' });
|
||||||
|
setStatusMessage('Von der Watchlist entfernt');
|
||||||
|
await loadWatchlist();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGrowthStats() {
|
||||||
|
if (!currentGuildId) return;
|
||||||
|
const res = await apiFetch<any>(`/growth?guildId=${encodeURIComponent(currentGuildId)}`);
|
||||||
|
setGrowthStats(res.stats || null);
|
||||||
|
}
|
||||||
|
|
||||||
const handleLogout = useCallback(() => {
|
const handleLogout = useCallback(() => {
|
||||||
window.location.href = `${appConfig.baseAuth || '/auth'}/logout`;
|
window.location.href = `${appConfig.baseAuth || '/auth'}/logout`;
|
||||||
}, []);
|
}, []);
|
||||||
@@ -587,7 +620,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
formDraft, editingFormId, registerTab, statusServiceDraft, statsItemDraft,
|
formDraft, editingFormId, registerTab, statusServiceDraft, statsItemDraft,
|
||||||
ticketDetail, ticketMessages, kbEditDraft, automationEditDraft,
|
ticketDetail, ticketMessages, kbEditDraft, automationEditDraft,
|
||||||
automodStrikes, registerStatusFilter, registerFormFilter, selectedAppId,
|
automodStrikes, registerStatusFilter, registerFormFilter, selectedAppId,
|
||||||
appNotes, appHistory, noteDraft, tasks, taskDraft,
|
appNotes, appHistory, noteDraft, tasks, taskDraft, watchlistEntries, growthStats,
|
||||||
setCurrentGuildId, setSection, setSettings, setBirthday, setSupportLogin,
|
setCurrentGuildId, setSection, setSettings, setBirthday, setSupportLogin,
|
||||||
setStatusDraft, setStatsDraft, setStatusMessage, loadGuildData,
|
setStatusDraft, setStatsDraft, setStatusMessage, loadGuildData,
|
||||||
saveSettingsPayload, saveBirthday, saveStatuspage, saveServerStats,
|
saveSettingsPayload, saveBirthday, saveStatuspage, saveServerStats,
|
||||||
@@ -603,6 +636,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
loadAutomodStrikes, resetAutomodStrike, setRegisterStatusFilter, setRegisterFormFilter,
|
loadAutomodStrikes, resetAutomodStrike, setRegisterStatusFilter, setRegisterFormFilter,
|
||||||
loadRegisterApps, openAppDetail, setNoteDraft, addAppNote,
|
loadRegisterApps, openAppDetail, setNoteDraft, addAppNote,
|
||||||
setTaskDraft, createTask, updateTaskStatus, deleteTask,
|
setTaskDraft, createTask, updateTaskStatus, deleteTask,
|
||||||
|
loadWatchlist, removeFromWatchlist, loadGrowthStats,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</AppContext.Provider>
|
</AppContext.Provider>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Card, CardContent, CardHeader, Chip } from '@heroui/react';
|
import { Card, CardContent, CardHeader, Chip } from '@heroui/react';
|
||||||
import { Activity, Clock, Server, Terminal } from 'lucide-react';
|
import { Activity, Award, ClipboardList, Clock, Eye, Handshake, Server, Terminal, Users } from 'lucide-react';
|
||||||
import { useApp } from '../context/AppContext';
|
import { useApp } from '../context/AppContext';
|
||||||
import { SectionCard } from '../components/shared/SectionCard';
|
import { SectionCard } from '../components/shared/SectionCard';
|
||||||
import { StatCard } from '../components/shared/StatCard';
|
import { StatCard } from '../components/shared/StatCard';
|
||||||
|
import { AppAvatar } from '../components/shared/AppAvatar';
|
||||||
import { BarComparisonChart } from '../components/shared/BarComparisonChart';
|
import { BarComparisonChart } from '../components/shared/BarComparisonChart';
|
||||||
import { formatDate, formatDuration } from '../utils/formatters';
|
import { formatDate, formatDuration, guildIconUrl } from '../utils/formatters';
|
||||||
|
|
||||||
const LEVEL_COLORS: Record<string, 'accent' | 'warning' | 'danger'> = {
|
const LEVEL_COLORS: Record<string, 'accent' | 'warning' | 'danger'> = {
|
||||||
INFO: 'accent',
|
INFO: 'accent',
|
||||||
@@ -20,6 +21,8 @@ export function Admin() {
|
|||||||
const overview = admin.overview || {};
|
const overview = admin.overview || {};
|
||||||
const activityPoints: { hour: string; count: number }[] = admin.activity || [];
|
const activityPoints: { hour: string; count: number }[] = admin.activity || [];
|
||||||
const logs: { timestamp: number; level: string; message: string; guildId?: string; category?: string }[] = admin.logs || [];
|
const logs: { timestamp: number; level: string; message: string; guildId?: string; category?: string }[] = admin.logs || [];
|
||||||
|
const guildList: { id: string; name: string; icon?: string; memberCount: number; boostCount: number }[] = admin.guildList || [];
|
||||||
|
const usage = admin.usage || {};
|
||||||
|
|
||||||
const chartItems = activityPoints.slice(-12).map((p) => ({
|
const chartItems = activityPoints.slice(-12).map((p) => ({
|
||||||
label: new Date(p.hour).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }),
|
label: new Date(p.hour).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }),
|
||||||
@@ -35,6 +38,14 @@ export function Admin() {
|
|||||||
<StatCard icon={<Terminal size={18} />} label="Log-Einträge" value={logs.length} />
|
<StatCard icon={<Terminal size={18} />} label="Log-Einträge" value={logs.length} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-sm font-semibold text-muted">Feature-Nutzung</h3>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<StatCard icon={<Award size={18} />} label="Abzeichen vergeben" value={usage.badgesAwarded ?? 0} color="accent" />
|
||||||
|
<StatCard icon={<ClipboardList size={18} />} label="Offene Aufgaben" value={usage.openTasks ?? 0} color="warning" />
|
||||||
|
<StatCard icon={<Eye size={18} />} label="Aktive Watchlist" value={usage.activeWatchlist ?? 0} color="danger" />
|
||||||
|
<StatCard icon={<Handshake size={18} />} label="Offene Partner-Anfragen" value={usage.pendingPartners ?? 0} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 grid gap-5 xl:grid-cols-2">
|
<div className="mt-5 grid gap-5 xl:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="px-5 pt-5 pb-0">
|
<CardHeader className="px-5 pt-5 pb-0">
|
||||||
@@ -54,33 +65,61 @@ export function Admin() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex items-center justify-between px-5 pt-5 pb-0">
|
<CardHeader className="flex items-center justify-between px-5 pt-5 pb-0">
|
||||||
<h3 className="text-base font-semibold">Letzte Admin Logs</h3>
|
<h3 className="text-base font-semibold">Server</h3>
|
||||||
<Chip size="sm" variant="soft" color="warning">
|
<Chip size="sm" variant="soft" color="accent">
|
||||||
{logs.length} Einträge
|
{guildList.length} Guilds
|
||||||
</Chip>
|
</Chip>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex max-h-96 flex-col gap-2 overflow-y-auto p-5">
|
<CardContent className="flex max-h-96 flex-col gap-2 overflow-y-auto p-5">
|
||||||
{logs.length ? logs.slice(0, 30).map((log, i) => (
|
{guildList.length ? guildList.map((g) => (
|
||||||
<div key={i} className="bg-surface-tertiary flex items-start gap-3 rounded-xl px-4 py-3 text-sm">
|
<div key={g.id} className="bg-surface-tertiary flex items-center gap-3 rounded-xl px-4 py-3 text-sm">
|
||||||
<Terminal size={14} className="mt-0.5 text-muted shrink-0" />
|
<AppAvatar size="sm" src={guildIconUrl(g as any)} name={g.name} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="truncate font-medium">{g.name}</div>
|
||||||
<Chip size="sm" variant="soft" color={LEVEL_COLORS[log.level] || 'accent'}>{log.level}</Chip>
|
<div className="flex items-center gap-3 text-xs text-muted">
|
||||||
{log.category && <span className="text-xs text-muted">{log.category}</span>}
|
<span className="flex items-center gap-1"><Users size={12} /> {g.memberCount}</span>
|
||||||
|
{g.boostCount > 0 && <span>💎 {g.boostCount}</span>}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-muted">{log.message || '-'}</p>
|
|
||||||
<p className="text-xs text-muted mt-0.5">{formatDate(log.timestamp)}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)) : (
|
)) : (
|
||||||
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
|
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
|
||||||
<Terminal size={20} />
|
<Server size={20} />
|
||||||
Keine Logs
|
Keine Server
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card className="mt-5">
|
||||||
|
<CardHeader className="flex items-center justify-between px-5 pt-5 pb-0">
|
||||||
|
<h3 className="text-base font-semibold">Letzte Admin Logs</h3>
|
||||||
|
<Chip size="sm" variant="soft" color="warning">
|
||||||
|
{logs.length} Einträge
|
||||||
|
</Chip>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex max-h-96 flex-col gap-2 overflow-y-auto p-5">
|
||||||
|
{logs.length ? logs.slice(0, 30).map((log, i) => (
|
||||||
|
<div key={i} className="bg-surface-tertiary flex items-start gap-3 rounded-xl px-4 py-3 text-sm">
|
||||||
|
<Terminal size={14} className="mt-0.5 text-muted shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Chip size="sm" variant="soft" color={LEVEL_COLORS[log.level] || 'accent'}>{log.level}</Chip>
|
||||||
|
{log.category && <span className="text-xs text-muted">{log.category}</span>}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-muted">{log.message || '-'}</p>
|
||||||
|
<p className="text-xs text-muted mt-0.5">{formatDate(log.timestamp)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
|
||||||
|
<Terminal size={20} />
|
||||||
|
Keine Logs
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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'
|
| 'modules'
|
||||||
| 'events'
|
| 'events'
|
||||||
| 'tasks'
|
| 'tasks'
|
||||||
|
| 'watchlist'
|
||||||
|
| 'branding'
|
||||||
|
| 'growth'
|
||||||
| 'admin';
|
| 'admin';
|
||||||
|
|
||||||
export type TicketRecord = {
|
export type TicketRecord = {
|
||||||
@@ -155,6 +158,27 @@ export type StaffTask = {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WatchlistEntry = {
|
||||||
|
id: string;
|
||||||
|
guildId: string;
|
||||||
|
userId: string;
|
||||||
|
reason?: string | null;
|
||||||
|
addedBy: string;
|
||||||
|
addedAt?: string;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GrowthStats = {
|
||||||
|
joins7: number;
|
||||||
|
leaves7: number;
|
||||||
|
joins30: number;
|
||||||
|
leaves30: number;
|
||||||
|
bestInvite: { code: string; uses: number } | null;
|
||||||
|
partnerJoins30: number;
|
||||||
|
boosts: number;
|
||||||
|
dailyJoins: { day: string; count: number }[];
|
||||||
|
};
|
||||||
|
|
||||||
export type MusicSession = {
|
export type MusicSession = {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
nowPlaying?: { title: string; url: string } | null;
|
nowPlaying?: { title: string; url: string } | null;
|
||||||
|
|||||||
165
node_modules/.prisma/client/edge.js
generated
vendored
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',
|
serverStatsConfig: 'serverStatsConfig',
|
||||||
lockdownConfig: 'lockdownConfig',
|
lockdownConfig: 'lockdownConfig',
|
||||||
tasksEnabled: 'tasksEnabled',
|
tasksEnabled: 'tasksEnabled',
|
||||||
|
badgesEnabled: 'badgesEnabled',
|
||||||
|
brandingConfig: 'brandingConfig',
|
||||||
|
partnerConfig: 'partnerConfig',
|
||||||
|
galleryConfig: 'galleryConfig',
|
||||||
supportRoleId: 'supportRoleId',
|
supportRoleId: 'supportRoleId',
|
||||||
updatedAt: 'updatedAt',
|
updatedAt: 'updatedAt',
|
||||||
createdAt: 'createdAt'
|
createdAt: 'createdAt'
|
||||||
@@ -329,6 +333,145 @@ exports.Prisma.StaffTaskScalarFieldEnum = {
|
|||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.Prisma.ModCaseScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
guildId: 'guildId',
|
||||||
|
userId: 'userId',
|
||||||
|
type: 'type',
|
||||||
|
reason: 'reason',
|
||||||
|
moderatorId: 'moderatorId',
|
||||||
|
moderatorTag: 'moderatorTag',
|
||||||
|
createdAt: 'createdAt'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.WatchlistScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
guildId: 'guildId',
|
||||||
|
userId: 'userId',
|
||||||
|
reason: 'reason',
|
||||||
|
addedBy: 'addedBy',
|
||||||
|
addedAt: 'addedAt',
|
||||||
|
active: 'active'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.UserBadgeScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
guildId: 'guildId',
|
||||||
|
userId: 'userId',
|
||||||
|
badgeKey: 'badgeKey',
|
||||||
|
awardedAt: 'awardedAt'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.UserActivityScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
guildId: 'guildId',
|
||||||
|
userId: 'userId',
|
||||||
|
messageCount: 'messageCount',
|
||||||
|
activeDays: 'activeDays',
|
||||||
|
lastActiveDay: 'lastActiveDay'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.WeeklyPlanScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
guildId: 'guildId',
|
||||||
|
channelId: 'channelId',
|
||||||
|
messageId: 'messageId',
|
||||||
|
title: 'title',
|
||||||
|
entries: 'entries',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.WeeklyPlanRsvpScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
planId: 'planId',
|
||||||
|
entryId: 'entryId',
|
||||||
|
userId: 'userId',
|
||||||
|
createdAt: 'createdAt'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.PollScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
guildId: 'guildId',
|
||||||
|
channelId: 'channelId',
|
||||||
|
messageId: 'messageId',
|
||||||
|
question: 'question',
|
||||||
|
options: 'options',
|
||||||
|
anonymous: 'anonymous',
|
||||||
|
closesAt: 'closesAt',
|
||||||
|
closed: 'closed',
|
||||||
|
createdBy: 'createdBy',
|
||||||
|
createdAt: 'createdAt'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.PollVoteScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
pollId: 'pollId',
|
||||||
|
userId: 'userId',
|
||||||
|
optionId: 'optionId',
|
||||||
|
createdAt: 'createdAt'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.SuggestionScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
guildId: 'guildId',
|
||||||
|
channelId: 'channelId',
|
||||||
|
messageId: 'messageId',
|
||||||
|
userId: 'userId',
|
||||||
|
userTag: 'userTag',
|
||||||
|
content: 'content',
|
||||||
|
status: 'status',
|
||||||
|
createdAt: 'createdAt'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.SuggestionVoteScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
suggestionId: 'suggestionId',
|
||||||
|
userId: 'userId',
|
||||||
|
value: 'value'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.PartnerRequestScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
guildId: 'guildId',
|
||||||
|
userId: 'userId',
|
||||||
|
userTag: 'userTag',
|
||||||
|
serverName: 'serverName',
|
||||||
|
inviteCode: 'inviteCode',
|
||||||
|
memberCount: 'memberCount',
|
||||||
|
description: 'description',
|
||||||
|
status: 'status',
|
||||||
|
reviewedBy: 'reviewedBy',
|
||||||
|
createdAt: 'createdAt'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.GalleryPostScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
guildId: 'guildId',
|
||||||
|
channelId: 'channelId',
|
||||||
|
messageId: 'messageId',
|
||||||
|
userId: 'userId',
|
||||||
|
userTag: 'userTag',
|
||||||
|
imageUrl: 'imageUrl',
|
||||||
|
caption: 'caption',
|
||||||
|
createdAt: 'createdAt'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.GalleryVoteScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
postId: 'postId',
|
||||||
|
userId: 'userId'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.GuildGrowthEventScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
guildId: 'guildId',
|
||||||
|
type: 'type',
|
||||||
|
userId: 'userId',
|
||||||
|
inviteCode: 'inviteCode',
|
||||||
|
createdAt: 'createdAt'
|
||||||
|
};
|
||||||
|
|
||||||
exports.Prisma.SortOrder = {
|
exports.Prisma.SortOrder = {
|
||||||
asc: 'asc',
|
asc: 'asc',
|
||||||
desc: 'desc'
|
desc: 'desc'
|
||||||
@@ -377,7 +520,21 @@ exports.Prisma.ModelName = {
|
|||||||
RegisterApplicationAnswer: 'RegisterApplicationAnswer',
|
RegisterApplicationAnswer: 'RegisterApplicationAnswer',
|
||||||
RegisterApplicationNote: 'RegisterApplicationNote',
|
RegisterApplicationNote: 'RegisterApplicationNote',
|
||||||
AutomodStrike: 'AutomodStrike',
|
AutomodStrike: 'AutomodStrike',
|
||||||
StaffTask: 'StaffTask'
|
StaffTask: 'StaffTask',
|
||||||
|
ModCase: 'ModCase',
|
||||||
|
Watchlist: 'Watchlist',
|
||||||
|
UserBadge: 'UserBadge',
|
||||||
|
UserActivity: 'UserActivity',
|
||||||
|
WeeklyPlan: 'WeeklyPlan',
|
||||||
|
WeeklyPlanRsvp: 'WeeklyPlanRsvp',
|
||||||
|
Poll: 'Poll',
|
||||||
|
PollVote: 'PollVote',
|
||||||
|
Suggestion: 'Suggestion',
|
||||||
|
SuggestionVote: 'SuggestionVote',
|
||||||
|
PartnerRequest: 'PartnerRequest',
|
||||||
|
GalleryPost: 'GalleryPost',
|
||||||
|
GalleryVote: 'GalleryVote',
|
||||||
|
GuildGrowthEvent: 'GuildGrowthEvent'
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
17714
node_modules/.prisma/client/index.d.ts
generated
vendored
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',
|
serverStatsConfig: 'serverStatsConfig',
|
||||||
lockdownConfig: 'lockdownConfig',
|
lockdownConfig: 'lockdownConfig',
|
||||||
tasksEnabled: 'tasksEnabled',
|
tasksEnabled: 'tasksEnabled',
|
||||||
|
badgesEnabled: 'badgesEnabled',
|
||||||
|
brandingConfig: 'brandingConfig',
|
||||||
|
partnerConfig: 'partnerConfig',
|
||||||
|
galleryConfig: 'galleryConfig',
|
||||||
supportRoleId: 'supportRoleId',
|
supportRoleId: 'supportRoleId',
|
||||||
updatedAt: 'updatedAt',
|
updatedAt: 'updatedAt',
|
||||||
createdAt: 'createdAt'
|
createdAt: 'createdAt'
|
||||||
@@ -329,6 +333,145 @@ exports.Prisma.StaffTaskScalarFieldEnum = {
|
|||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.Prisma.ModCaseScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
guildId: 'guildId',
|
||||||
|
userId: 'userId',
|
||||||
|
type: 'type',
|
||||||
|
reason: 'reason',
|
||||||
|
moderatorId: 'moderatorId',
|
||||||
|
moderatorTag: 'moderatorTag',
|
||||||
|
createdAt: 'createdAt'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.WatchlistScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
guildId: 'guildId',
|
||||||
|
userId: 'userId',
|
||||||
|
reason: 'reason',
|
||||||
|
addedBy: 'addedBy',
|
||||||
|
addedAt: 'addedAt',
|
||||||
|
active: 'active'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.UserBadgeScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
guildId: 'guildId',
|
||||||
|
userId: 'userId',
|
||||||
|
badgeKey: 'badgeKey',
|
||||||
|
awardedAt: 'awardedAt'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.UserActivityScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
guildId: 'guildId',
|
||||||
|
userId: 'userId',
|
||||||
|
messageCount: 'messageCount',
|
||||||
|
activeDays: 'activeDays',
|
||||||
|
lastActiveDay: 'lastActiveDay'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.WeeklyPlanScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
guildId: 'guildId',
|
||||||
|
channelId: 'channelId',
|
||||||
|
messageId: 'messageId',
|
||||||
|
title: 'title',
|
||||||
|
entries: 'entries',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.WeeklyPlanRsvpScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
planId: 'planId',
|
||||||
|
entryId: 'entryId',
|
||||||
|
userId: 'userId',
|
||||||
|
createdAt: 'createdAt'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.PollScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
guildId: 'guildId',
|
||||||
|
channelId: 'channelId',
|
||||||
|
messageId: 'messageId',
|
||||||
|
question: 'question',
|
||||||
|
options: 'options',
|
||||||
|
anonymous: 'anonymous',
|
||||||
|
closesAt: 'closesAt',
|
||||||
|
closed: 'closed',
|
||||||
|
createdBy: 'createdBy',
|
||||||
|
createdAt: 'createdAt'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.PollVoteScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
pollId: 'pollId',
|
||||||
|
userId: 'userId',
|
||||||
|
optionId: 'optionId',
|
||||||
|
createdAt: 'createdAt'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.SuggestionScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
guildId: 'guildId',
|
||||||
|
channelId: 'channelId',
|
||||||
|
messageId: 'messageId',
|
||||||
|
userId: 'userId',
|
||||||
|
userTag: 'userTag',
|
||||||
|
content: 'content',
|
||||||
|
status: 'status',
|
||||||
|
createdAt: 'createdAt'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.SuggestionVoteScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
suggestionId: 'suggestionId',
|
||||||
|
userId: 'userId',
|
||||||
|
value: 'value'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.PartnerRequestScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
guildId: 'guildId',
|
||||||
|
userId: 'userId',
|
||||||
|
userTag: 'userTag',
|
||||||
|
serverName: 'serverName',
|
||||||
|
inviteCode: 'inviteCode',
|
||||||
|
memberCount: 'memberCount',
|
||||||
|
description: 'description',
|
||||||
|
status: 'status',
|
||||||
|
reviewedBy: 'reviewedBy',
|
||||||
|
createdAt: 'createdAt'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.GalleryPostScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
guildId: 'guildId',
|
||||||
|
channelId: 'channelId',
|
||||||
|
messageId: 'messageId',
|
||||||
|
userId: 'userId',
|
||||||
|
userTag: 'userTag',
|
||||||
|
imageUrl: 'imageUrl',
|
||||||
|
caption: 'caption',
|
||||||
|
createdAt: 'createdAt'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.GalleryVoteScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
postId: 'postId',
|
||||||
|
userId: 'userId'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.GuildGrowthEventScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
guildId: 'guildId',
|
||||||
|
type: 'type',
|
||||||
|
userId: 'userId',
|
||||||
|
inviteCode: 'inviteCode',
|
||||||
|
createdAt: 'createdAt'
|
||||||
|
};
|
||||||
|
|
||||||
exports.Prisma.SortOrder = {
|
exports.Prisma.SortOrder = {
|
||||||
asc: 'asc',
|
asc: 'asc',
|
||||||
desc: 'desc'
|
desc: 'desc'
|
||||||
@@ -377,7 +520,21 @@ exports.Prisma.ModelName = {
|
|||||||
RegisterApplicationAnswer: 'RegisterApplicationAnswer',
|
RegisterApplicationAnswer: 'RegisterApplicationAnswer',
|
||||||
RegisterApplicationNote: 'RegisterApplicationNote',
|
RegisterApplicationNote: 'RegisterApplicationNote',
|
||||||
AutomodStrike: 'AutomodStrike',
|
AutomodStrike: 'AutomodStrike',
|
||||||
StaffTask: 'StaffTask'
|
StaffTask: 'StaffTask',
|
||||||
|
ModCase: 'ModCase',
|
||||||
|
Watchlist: 'Watchlist',
|
||||||
|
UserBadge: 'UserBadge',
|
||||||
|
UserActivity: 'UserActivity',
|
||||||
|
WeeklyPlan: 'WeeklyPlan',
|
||||||
|
WeeklyPlanRsvp: 'WeeklyPlanRsvp',
|
||||||
|
Poll: 'Poll',
|
||||||
|
PollVote: 'PollVote',
|
||||||
|
Suggestion: 'Suggestion',
|
||||||
|
SuggestionVote: 'SuggestionVote',
|
||||||
|
PartnerRequest: 'PartnerRequest',
|
||||||
|
GalleryPost: 'GalleryPost',
|
||||||
|
GalleryVote: 'GalleryVote',
|
||||||
|
GuildGrowthEvent: 'GuildGrowthEvent'
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ const command: SlashCommand = {
|
|||||||
await member.ban({ reason }).catch(() => null);
|
await member.ban({ reason }).catch(() => null);
|
||||||
await interaction.reply({ content: `${user.tag} wurde gebannt. Grund: ${reason}` });
|
await interaction.reply({ content: `${user.tag} wurde gebannt. Grund: ${reason}` });
|
||||||
context.logging.logAction(user, 'Ban', reason, interaction.guild);
|
context.logging.logAction(user, 'Ban', reason, interaction.guild);
|
||||||
|
context.modCases.recordCase(interaction.guild.id, user.id, 'ban', reason, interaction.user.id, interaction.user.tag);
|
||||||
|
context.watchlist.notifyIfWatched(interaction.guild, user.id, 'Gebannt', reason);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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 member.kick(reason);
|
||||||
await interaction.reply({ content: `${user.tag} wurde gekickt.` });
|
await interaction.reply({ content: `${user.tag} wurde gekickt.` });
|
||||||
context.logging.logAction(user, 'Kick', reason, interaction.guild);
|
context.logging.logAction(user, 'Kick', reason, interaction.guild);
|
||||||
|
context.modCases.recordCase(interaction.guild.id, user.id, 'kick', reason, interaction.user.id, interaction.user.tag);
|
||||||
|
context.watchlist.notifyIfWatched(interaction.guild, user.id, 'Gekickt', reason);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ const command: SlashCommand = {
|
|||||||
await member.timeout(minutes * 60 * 1000, reason).catch(() => null);
|
await member.timeout(minutes * 60 * 1000, reason).catch(() => null);
|
||||||
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gemutet.` });
|
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gemutet.` });
|
||||||
context.logging.logAction(user, 'Mute', reason, interaction.guild);
|
context.logging.logAction(user, 'Mute', reason, interaction.guild);
|
||||||
|
context.modCases.recordCase(interaction.guild.id, user.id, 'mute', reason, interaction.user.id, interaction.user.tag);
|
||||||
|
context.watchlist.notifyIfWatched(interaction.guild, user.id, 'Gemutet', reason);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ const command: SlashCommand = {
|
|||||||
await member.ban({ reason: `${reason} | ${minutes} Minuten` });
|
await member.ban({ reason: `${reason} | ${minutes} Minuten` });
|
||||||
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gebannt.` });
|
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gebannt.` });
|
||||||
context.logging.logAction(user, 'Tempban', reason, interaction.guild);
|
context.logging.logAction(user, 'Tempban', reason, interaction.guild);
|
||||||
|
context.modCases.recordCase(interaction.guild.id, user.id, 'tempban', reason, interaction.user.id, interaction.user.tag);
|
||||||
|
context.watchlist.notifyIfWatched(interaction.guild, user.id, 'Temporär gebannt', reason);
|
||||||
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
await interaction.guild?.members.unban(user.id, 'Tempban abgelaufen').catch(() => null);
|
await interaction.guild?.members.unban(user.id, 'Tempban abgelaufen').catch(() => null);
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ const command: SlashCommand = {
|
|||||||
await member.timeout(minutes * 60 * 1000, reason).catch(() => null);
|
await member.timeout(minutes * 60 * 1000, reason).catch(() => null);
|
||||||
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten in Timeout gesetzt.` });
|
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten in Timeout gesetzt.` });
|
||||||
context.logging.logAction(user, 'Timeout', reason, interaction.guild);
|
context.logging.logAction(user, 'Timeout', reason, interaction.guild);
|
||||||
|
context.modCases.recordCase(interaction.guild.id, user.id, 'timeout', reason, interaction.user.id, interaction.user.tag);
|
||||||
|
context.watchlist.notifyIfWatched(interaction.guild, user.id, 'Timeout erhalten', reason);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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 { TaskService } from '../services/taskService';
|
||||||
import { SetupWizardService } from '../services/setupService';
|
import { SetupWizardService } from '../services/setupService';
|
||||||
import { EmbedBuilderService } from '../services/embedBuilderService';
|
import { EmbedBuilderService } from '../services/embedBuilderService';
|
||||||
|
import { ModCaseService } from '../services/modCaseService';
|
||||||
|
import { WatchlistService } from '../services/watchlistService';
|
||||||
|
import { BadgeService } from '../services/badgeService';
|
||||||
|
import { WeeklyPlanService } from '../services/weeklyPlanService';
|
||||||
|
import { PollService } from '../services/pollService';
|
||||||
|
import { SuggestionService } from '../services/suggestionService';
|
||||||
|
import { BrandingService } from '../services/brandingService';
|
||||||
|
import { RulesService } from '../services/rulesService';
|
||||||
|
import { PartnerService } from '../services/partnerService';
|
||||||
|
import { AltAccountService } from '../services/altAccountService';
|
||||||
|
import { GalleryService } from '../services/galleryService';
|
||||||
|
import { GrowthService } from '../services/growthService';
|
||||||
|
|
||||||
const logging = new LoggingService();
|
const logging = new LoggingService();
|
||||||
const moduleService = new BotModuleService();
|
const moduleService = new BotModuleService();
|
||||||
const ticketService = new TicketService();
|
const ticketService = new TicketService();
|
||||||
|
const birthdayService = new BirthdayService();
|
||||||
|
const eventService = new EventService();
|
||||||
|
const badgeService = new BadgeService();
|
||||||
|
|
||||||
export const context = {
|
export const context = {
|
||||||
client: null as Client | null,
|
client: null as Client | null,
|
||||||
@@ -37,9 +52,9 @@ export const context = {
|
|||||||
modules: moduleService,
|
modules: moduleService,
|
||||||
admin: new AdminService(),
|
admin: new AdminService(),
|
||||||
statuspage: new StatuspageService(),
|
statuspage: new StatuspageService(),
|
||||||
birthdays: new BirthdayService(),
|
birthdays: birthdayService,
|
||||||
reactionRoles: new ReactionRoleService(),
|
reactionRoles: new ReactionRoleService(),
|
||||||
events: new EventService(),
|
events: eventService,
|
||||||
ticketAutomation: new TicketAutomationService(),
|
ticketAutomation: new TicketAutomationService(),
|
||||||
knowledgeBase: new KnowledgeBaseService(),
|
knowledgeBase: new KnowledgeBaseService(),
|
||||||
register: new RegisterService(),
|
register: new RegisterService(),
|
||||||
@@ -47,9 +62,24 @@ export const context = {
|
|||||||
lockdown: new LockdownService(),
|
lockdown: new LockdownService(),
|
||||||
tasks: new TaskService(),
|
tasks: new TaskService(),
|
||||||
setup: new SetupWizardService(moduleService, ticketService),
|
setup: new SetupWizardService(moduleService, ticketService),
|
||||||
embedBuilder: new EmbedBuilderService()
|
embedBuilder: new EmbedBuilderService(),
|
||||||
|
modCases: new ModCaseService(),
|
||||||
|
watchlist: new WatchlistService(logging),
|
||||||
|
badges: badgeService,
|
||||||
|
weeklyPlan: new WeeklyPlanService(),
|
||||||
|
polls: new PollService(),
|
||||||
|
suggestions: new SuggestionService(),
|
||||||
|
branding: new BrandingService(),
|
||||||
|
rules: new RulesService(),
|
||||||
|
partners: new PartnerService(),
|
||||||
|
altAccounts: new AltAccountService(),
|
||||||
|
gallery: new GalleryService(),
|
||||||
|
growth: new GrowthService()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
birthdayService.setBadgeService(badgeService);
|
||||||
|
eventService.setBadgeService(badgeService);
|
||||||
|
|
||||||
context.modules.setHooks({
|
context.modules.setHooks({
|
||||||
musicEnabled: {
|
musicEnabled: {
|
||||||
onDisable: async () => context.music.stopAll()
|
onDisable: async () => context.music.stopAll()
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export interface GuildSettings {
|
|||||||
supportRoleId?: string;
|
supportRoleId?: string;
|
||||||
welcomeEnabled?: boolean;
|
welcomeEnabled?: boolean;
|
||||||
tasksEnabled?: boolean;
|
tasksEnabled?: boolean;
|
||||||
|
badgesEnabled?: boolean;
|
||||||
lockdownConfig?: {
|
lockdownConfig?: {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
activatedAt?: string;
|
activatedAt?: string;
|
||||||
@@ -86,6 +87,22 @@ export interface GuildSettings {
|
|||||||
staffRoleId?: string;
|
staffRoleId?: string;
|
||||||
snapshot?: { channelId: string; sendMessages: boolean | null }[];
|
snapshot?: { channelId: string; sendMessages: boolean | null }[];
|
||||||
};
|
};
|
||||||
|
brandingConfig?: {
|
||||||
|
embedColor?: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
footerText?: string;
|
||||||
|
botName?: string;
|
||||||
|
theme?: 'orange' | 'blue' | 'green' | 'purple' | 'red';
|
||||||
|
};
|
||||||
|
partnerConfig?: {
|
||||||
|
reviewChannelId?: string;
|
||||||
|
showcaseChannelId?: string;
|
||||||
|
};
|
||||||
|
galleryConfig?: {
|
||||||
|
channelId?: string;
|
||||||
|
artistRoleId?: string;
|
||||||
|
lastWinnerAt?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class SettingsStore {
|
class SettingsStore {
|
||||||
@@ -105,7 +122,8 @@ class SettingsStore {
|
|||||||
'reactionRolesEnabled',
|
'reactionRolesEnabled',
|
||||||
'eventsEnabled',
|
'eventsEnabled',
|
||||||
'registerEnabled',
|
'registerEnabled',
|
||||||
'tasksEnabled'
|
'tasksEnabled',
|
||||||
|
'badgesEnabled'
|
||||||
] as const;
|
] as const;
|
||||||
defaultOn.forEach((key) => {
|
defaultOn.forEach((key) => {
|
||||||
if (normalized[key] === undefined) normalized[key] = true;
|
if (normalized[key] === undefined) normalized[key] = true;
|
||||||
@@ -148,6 +166,10 @@ class SettingsStore {
|
|||||||
serverStatsConfig: (row as any).serverStatsConfig ?? undefined,
|
serverStatsConfig: (row as any).serverStatsConfig ?? undefined,
|
||||||
lockdownConfig: (row as any).lockdownConfig ?? undefined,
|
lockdownConfig: (row as any).lockdownConfig ?? undefined,
|
||||||
tasksEnabled: (row as any).tasksEnabled ?? undefined,
|
tasksEnabled: (row as any).tasksEnabled ?? undefined,
|
||||||
|
badgesEnabled: (row as any).badgesEnabled ?? undefined,
|
||||||
|
brandingConfig: (row as any).brandingConfig ?? undefined,
|
||||||
|
partnerConfig: (row as any).partnerConfig ?? undefined,
|
||||||
|
galleryConfig: (row as any).galleryConfig ?? undefined,
|
||||||
supportRoleId: row.supportRoleId ?? undefined
|
supportRoleId: row.supportRoleId ?? undefined
|
||||||
} satisfies GuildSettings;
|
} satisfies GuildSettings;
|
||||||
this.cache.set(row.guildId, this.applyModuleDefaults(cfg));
|
this.cache.set(row.guildId, this.applyModuleDefaults(cfg));
|
||||||
@@ -206,6 +228,15 @@ class SettingsStore {
|
|||||||
if (partial.lockdownConfig) {
|
if (partial.lockdownConfig) {
|
||||||
merged.lockdownConfig = { ...(merged.lockdownConfig ?? {}), ...partial.lockdownConfig };
|
merged.lockdownConfig = { ...(merged.lockdownConfig ?? {}), ...partial.lockdownConfig };
|
||||||
}
|
}
|
||||||
|
if (partial.brandingConfig) {
|
||||||
|
merged.brandingConfig = { ...(merged.brandingConfig ?? {}), ...partial.brandingConfig };
|
||||||
|
}
|
||||||
|
if (partial.partnerConfig) {
|
||||||
|
merged.partnerConfig = { ...(merged.partnerConfig ?? {}), ...partial.partnerConfig };
|
||||||
|
}
|
||||||
|
if (partial.galleryConfig) {
|
||||||
|
merged.galleryConfig = { ...(merged.galleryConfig ?? {}), ...partial.galleryConfig };
|
||||||
|
}
|
||||||
merged.automodConfig = { ...mergedAutomod, supportLoginConfig: merged.supportLoginConfig ?? mergedAutomod['supportLoginConfig'] };
|
merged.automodConfig = { ...mergedAutomod, supportLoginConfig: merged.supportLoginConfig ?? mergedAutomod['supportLoginConfig'] };
|
||||||
merged.statuspageEnabled = mergedAutomod.statuspageEnabled;
|
merged.statuspageEnabled = mergedAutomod.statuspageEnabled;
|
||||||
merged.statuspageConfig = mergedAutomod.statuspageConfig;
|
merged.statuspageConfig = mergedAutomod.statuspageConfig;
|
||||||
@@ -237,6 +268,10 @@ class SettingsStore {
|
|||||||
serverStatsConfig: (merged as any).serverStatsConfig ?? null,
|
serverStatsConfig: (merged as any).serverStatsConfig ?? null,
|
||||||
lockdownConfig: merged.lockdownConfig ?? Prisma.JsonNull,
|
lockdownConfig: merged.lockdownConfig ?? Prisma.JsonNull,
|
||||||
tasksEnabled: merged.tasksEnabled ?? null,
|
tasksEnabled: merged.tasksEnabled ?? null,
|
||||||
|
badgesEnabled: merged.badgesEnabled ?? null,
|
||||||
|
brandingConfig: merged.brandingConfig ?? Prisma.JsonNull,
|
||||||
|
partnerConfig: merged.partnerConfig ?? Prisma.JsonNull,
|
||||||
|
galleryConfig: merged.galleryConfig ?? Prisma.JsonNull,
|
||||||
supportRoleId: merged.supportRoleId ?? null
|
supportRoleId: merged.supportRoleId ?? null
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
@@ -263,6 +298,10 @@ class SettingsStore {
|
|||||||
serverStatsConfig: (merged as any).serverStatsConfig ?? null,
|
serverStatsConfig: (merged as any).serverStatsConfig ?? null,
|
||||||
lockdownConfig: merged.lockdownConfig ?? Prisma.JsonNull,
|
lockdownConfig: merged.lockdownConfig ?? Prisma.JsonNull,
|
||||||
tasksEnabled: merged.tasksEnabled ?? null,
|
tasksEnabled: merged.tasksEnabled ?? null,
|
||||||
|
badgesEnabled: merged.badgesEnabled ?? null,
|
||||||
|
brandingConfig: merged.brandingConfig ?? Prisma.JsonNull,
|
||||||
|
partnerConfig: merged.partnerConfig ?? Prisma.JsonNull,
|
||||||
|
galleryConfig: merged.galleryConfig ?? Prisma.JsonNull,
|
||||||
supportRoleId: merged.supportRoleId ?? null
|
supportRoleId: merged.supportRoleId ?? null
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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?
|
serverStatsConfig Json?
|
||||||
lockdownConfig Json?
|
lockdownConfig Json?
|
||||||
tasksEnabled Boolean?
|
tasksEnabled Boolean?
|
||||||
|
badgesEnabled Boolean?
|
||||||
|
brandingConfig Json?
|
||||||
|
partnerConfig Json?
|
||||||
|
galleryConfig Json?
|
||||||
supportRoleId String?
|
supportRoleId String?
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -259,3 +263,186 @@ model StaffTask {
|
|||||||
|
|
||||||
@@index([guildId, status])
|
@@index([guildId, status])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ModCase {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
userId String
|
||||||
|
type String
|
||||||
|
reason String?
|
||||||
|
moderatorId String
|
||||||
|
moderatorTag String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([guildId, userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Watchlist {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
userId String
|
||||||
|
reason String?
|
||||||
|
addedBy String
|
||||||
|
addedAt DateTime @default(now())
|
||||||
|
active Boolean @default(true)
|
||||||
|
|
||||||
|
@@unique([guildId, userId])
|
||||||
|
@@index([guildId, active])
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserBadge {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
userId String
|
||||||
|
badgeKey String
|
||||||
|
awardedAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([guildId, userId, badgeKey])
|
||||||
|
@@index([guildId, userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserActivity {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
userId String
|
||||||
|
messageCount Int @default(0)
|
||||||
|
activeDays Int @default(0)
|
||||||
|
lastActiveDay String?
|
||||||
|
|
||||||
|
@@unique([guildId, userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model WeeklyPlan {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
channelId String
|
||||||
|
messageId String?
|
||||||
|
title String @default("Wochenplan")
|
||||||
|
entries Json
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([guildId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model WeeklyPlanRsvp {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
planId String
|
||||||
|
entryId String
|
||||||
|
userId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([planId, entryId, userId])
|
||||||
|
@@index([planId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Poll {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
channelId String
|
||||||
|
messageId String?
|
||||||
|
question String
|
||||||
|
options Json
|
||||||
|
anonymous Boolean @default(false)
|
||||||
|
closesAt DateTime?
|
||||||
|
closed Boolean @default(false)
|
||||||
|
createdBy String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
votes PollVote[]
|
||||||
|
|
||||||
|
@@index([guildId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model PollVote {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
pollId String
|
||||||
|
userId String
|
||||||
|
optionId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
poll Poll @relation(fields: [pollId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([pollId, userId])
|
||||||
|
@@index([pollId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Suggestion {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
channelId String
|
||||||
|
messageId String?
|
||||||
|
userId String
|
||||||
|
userTag String
|
||||||
|
content String
|
||||||
|
status String @default("pending")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
votes SuggestionVote[]
|
||||||
|
|
||||||
|
@@index([guildId, status])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SuggestionVote {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
suggestionId String
|
||||||
|
userId String
|
||||||
|
value Int
|
||||||
|
|
||||||
|
suggestion Suggestion @relation(fields: [suggestionId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([suggestionId, userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model PartnerRequest {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
userId String
|
||||||
|
userTag String
|
||||||
|
serverName String
|
||||||
|
inviteCode String
|
||||||
|
memberCount Int?
|
||||||
|
description String?
|
||||||
|
status String @default("pending")
|
||||||
|
reviewedBy String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([guildId, status])
|
||||||
|
}
|
||||||
|
|
||||||
|
model GalleryPost {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
channelId String
|
||||||
|
messageId String?
|
||||||
|
userId String
|
||||||
|
userTag String
|
||||||
|
imageUrl String
|
||||||
|
caption String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
votes GalleryVote[]
|
||||||
|
|
||||||
|
@@index([guildId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model GalleryVote {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
postId String
|
||||||
|
userId String
|
||||||
|
|
||||||
|
post GalleryPost @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([postId, userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model GuildGrowthEvent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
type String
|
||||||
|
userId String
|
||||||
|
inviteCode String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([guildId, type, createdAt])
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const event: EventHandler = {
|
|||||||
if (context.commandHandler) {
|
if (context.commandHandler) {
|
||||||
await context.commandHandler.registerGuildCommands(guild.id);
|
await context.commandHandler.registerGuildCommands(guild.id);
|
||||||
}
|
}
|
||||||
|
context.growth.cacheInvites(guild).catch(() => undefined);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ const event: EventHandler = {
|
|||||||
context.logging.logMemberJoin(member);
|
context.logging.logMemberJoin(member);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
context.altAccounts.check(member).then(({ score, reasons }) => {
|
||||||
|
if (context.altAccounts.isSuspicious(score)) context.logging.logSuspiciousJoin(member, reasons);
|
||||||
|
}).catch(() => undefined);
|
||||||
|
|
||||||
|
context.growth.resolveUsedInvite(member.guild).then((inviteCode) => {
|
||||||
|
context.growth.recordJoin(member.guild.id, member.id, inviteCode);
|
||||||
|
}).catch(() => undefined);
|
||||||
|
|
||||||
const guildConfig = settingsStore.get(member.guild.id);
|
const guildConfig = settingsStore.get(member.guild.id);
|
||||||
const welcomeCfg = guildConfig?.welcomeConfig || guildConfig?.automodConfig?.welcomeConfig;
|
const welcomeCfg = guildConfig?.welcomeConfig || guildConfig?.automodConfig?.welcomeConfig;
|
||||||
if (welcomeCfg?.enabled && welcomeCfg.channelId) {
|
if (welcomeCfg?.enabled && welcomeCfg.channelId) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const event: EventHandler = {
|
|||||||
execute(member: GuildMember) {
|
execute(member: GuildMember) {
|
||||||
context.logging.logMemberLeave(member);
|
context.logging.logMemberLeave(member);
|
||||||
context.stats.refreshGuild(member.guild.id).catch(() => undefined);
|
context.stats.refreshGuild(member.guild.id).catch(() => undefined);
|
||||||
|
context.growth.recordLeave(member.guild.id, member.id).catch(() => undefined);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ const event: EventHandler = {
|
|||||||
name: 'guildMemberUpdate',
|
name: 'guildMemberUpdate',
|
||||||
execute(oldMember: GuildMember | PartialGuildMember, newMember: GuildMember) {
|
execute(oldMember: GuildMember | PartialGuildMember, newMember: GuildMember) {
|
||||||
if (!newMember.guild) return;
|
if (!newMember.guild) return;
|
||||||
|
if (!oldMember.premiumSince && newMember.premiumSince) {
|
||||||
|
context.badges.award(newMember.guild.id, newMember.id, 'server_booster');
|
||||||
|
}
|
||||||
const before = new Set(oldMember.roles?.cache?.keys?.() || []);
|
const before = new Set(oldMember.roles?.cache?.keys?.() || []);
|
||||||
const after = new Set(newMember.roles.cache.keys());
|
const after = new Set(newMember.roles.cache.keys());
|
||||||
const added = Array.from(after).filter((id) => !before.has(id));
|
const added = Array.from(after).filter((id) => !before.has(id));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Interaction } from 'discord.js';
|
|||||||
import { EventHandler } from '../utils/types';
|
import { EventHandler } from '../utils/types';
|
||||||
import { context } from '../config/context';
|
import { context } from '../config/context';
|
||||||
import { buildTaskCard } from '../commands/utility/task';
|
import { buildTaskCard } from '../commands/utility/task';
|
||||||
|
import { handleBrandingModal } from '../commands/utility/branding';
|
||||||
|
|
||||||
const event: EventHandler = {
|
const event: EventHandler = {
|
||||||
name: 'interactionCreate',
|
name: 'interactionCreate',
|
||||||
@@ -13,7 +14,7 @@ const event: EventHandler = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isChannelSelectMenu()) {
|
if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isChannelSelectMenu() || interaction.isRoleSelectMenu()) {
|
||||||
if (interaction.customId.startsWith('setup:')) {
|
if (interaction.customId.startsWith('setup:')) {
|
||||||
await context.setup.handleComponent(interaction as any);
|
await context.setup.handleComponent(interaction as any);
|
||||||
return;
|
return;
|
||||||
@@ -28,6 +29,26 @@ const event: EventHandler = {
|
|||||||
await interaction.update(buildTaskCard(task));
|
await interaction.update(buildTaskCard(task));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (interaction.isButton() && interaction.customId.startsWith('weekplan:')) {
|
||||||
|
await context.weeklyPlan.handleComponent(interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (interaction.isButton() && interaction.customId.startsWith('poll:')) {
|
||||||
|
await context.polls.handleComponent(interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (interaction.isButton() && interaction.customId.startsWith('suggestion:')) {
|
||||||
|
await context.suggestions.handleComponent(interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (interaction.isButton() && interaction.customId.startsWith('partner:')) {
|
||||||
|
await context.partners.handleComponent(interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (interaction.isButton() && interaction.customId.startsWith('gallery:')) {
|
||||||
|
await context.gallery.handleComponent(interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.isButton()) {
|
if (interaction.isButton()) {
|
||||||
@@ -54,6 +75,18 @@ const event: EventHandler = {
|
|||||||
await context.embedBuilder.handleModal(interaction);
|
await context.embedBuilder.handleModal(interaction);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (interaction.customId.startsWith('weekplan:create:')) {
|
||||||
|
await context.weeklyPlan.handleModal(interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (interaction.customId === 'branding:set') {
|
||||||
|
await handleBrandingModal(interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (interaction.customId === 'partner:apply') {
|
||||||
|
await context.partners.handleModal(interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const event: EventHandler = {
|
|||||||
if (message.guildId) context.admin.trackEvent('message', message.guildId);
|
if (message.guildId) context.admin.trackEvent('message', message.guildId);
|
||||||
if (cfg?.automodEnabled === true) context.automod.checkMessage(message, cfg);
|
if (cfg?.automodEnabled === true) context.automod.checkMessage(message, cfg);
|
||||||
if (cfg?.levelingEnabled === true) context.leveling.handleMessage(message);
|
if (cfg?.levelingEnabled === true) context.leveling.handleMessage(message);
|
||||||
|
if (cfg?.badgesEnabled !== false) context.badges.trackMessage(message);
|
||||||
// Ticket SLA + KB
|
// Ticket SLA + KB
|
||||||
await context.tickets.trackFirstResponse(message);
|
await context.tickets.trackFirstResponse(message);
|
||||||
await context.tickets.suggestKnowledgeBase(message);
|
await context.tickets.suggestKnowledgeBase(message);
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ const event: EventHandler = {
|
|||||||
name: 'messageDelete',
|
name: 'messageDelete',
|
||||||
execute(message: Message) {
|
execute(message: Message) {
|
||||||
context.logging.logMessageDelete(message);
|
context.logging.logMessageDelete(message);
|
||||||
|
if (message.guild && message.author) {
|
||||||
|
context.watchlist.notifyIfWatched(message.guild, message.author.id, 'Nachricht gelöscht', message.content || undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ const event: EventHandler = {
|
|||||||
for (const [gid] of client.guilds.cache) {
|
for (const [gid] of client.guilds.cache) {
|
||||||
context.stats.refreshGuild(gid).catch((err) => logger.warn(`stats refresh failed for ${gid}: ${err}`));
|
context.stats.refreshGuild(gid).catch((err) => logger.warn(`stats refresh failed for ${gid}: ${err}`));
|
||||||
}
|
}
|
||||||
|
for (const [, guild] of client.guilds.cache) {
|
||||||
|
context.growth.cacheInvites(guild).catch(() => undefined);
|
||||||
|
}
|
||||||
|
context.gallery.startScheduler();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(`Ready handler failed: ${err}`);
|
logger.warn(`Ready handler failed: ${err}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { settingsStore } from './config/state';
|
|||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
context.admin.setStartTime(Date.now());
|
context.admin.setStartTime(Date.now());
|
||||||
await settingsStore.init();
|
await settingsStore.init();
|
||||||
|
await context.watchlist.loadAll();
|
||||||
|
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
intents: [
|
intents: [
|
||||||
@@ -36,6 +37,8 @@ async function bootstrap() {
|
|||||||
context.events.startScheduler();
|
context.events.startScheduler();
|
||||||
context.register.setClient(client);
|
context.register.setClient(client);
|
||||||
context.stats.setClient(client);
|
context.stats.setClient(client);
|
||||||
|
context.polls.setClient(client);
|
||||||
|
context.polls.startScheduler();
|
||||||
await context.reactionRoles.loadCache();
|
await context.reactionRoles.loadCache();
|
||||||
logger.setSink((entry) => context.admin.pushLog(entry));
|
logger.setSink((entry) => context.admin.pushLog(entry));
|
||||||
for (const gid of settingsStore.all().keys()) {
|
for (const gid of settingsStore.all().keys()) {
|
||||||
|
|||||||
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 { GuildSettings } from '../config/state';
|
||||||
import { LoggingService } from './loggingService';
|
import { LoggingService } from './loggingService';
|
||||||
import { prisma } from '../database';
|
import { prisma } from '../database';
|
||||||
|
import { context } from '../config/context';
|
||||||
|
|
||||||
export type AutomodFilterKey =
|
export type AutomodFilterKey =
|
||||||
| 'linkFilter'
|
| 'linkFilter'
|
||||||
@@ -284,6 +285,7 @@ export class AutoModService {
|
|||||||
await prisma.automodStrike.create({
|
await prisma.automodStrike.create({
|
||||||
data: { guildId: message.guildId, userId: message.author.id, filterKey, weight: 1, reason }
|
data: { guildId: message.guildId, userId: message.author.id, filterKey, weight: 1, reason }
|
||||||
});
|
});
|
||||||
|
context.watchlist.notifyIfWatched(message.guild, message.author.id, 'Automod-Verstoß', reason);
|
||||||
const decayHours = strikeCfg.decayHours ?? 0;
|
const decayHours = strikeCfg.decayHours ?? 0;
|
||||||
const since = decayHours > 0 ? new Date(Date.now() - decayHours * 60 * 60 * 1000) : undefined;
|
const since = decayHours > 0 ? new Date(Date.now() - decayHours * 60 * 60 * 1000) : undefined;
|
||||||
const rows = await prisma.automodStrike.findMany({
|
const rows = await prisma.automodStrike.findMany({
|
||||||
|
|||||||
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 { prisma } from '../database';
|
||||||
import { settingsStore } from '../config/state';
|
import { settingsStore } from '../config/state';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
import { BadgeService } from './badgeService';
|
||||||
|
|
||||||
export function normalizeBirthdayInput(raw: string) {
|
export function normalizeBirthdayInput(raw: string) {
|
||||||
const input = (raw || '').trim();
|
const input = (raw || '').trim();
|
||||||
@@ -60,11 +61,16 @@ export class BirthdayService {
|
|||||||
private client: Client | null = null;
|
private client: Client | null = null;
|
||||||
private timer: NodeJS.Timeout | null = null;
|
private timer: NodeJS.Timeout | null = null;
|
||||||
private lastSent = new Map<string, string>();
|
private lastSent = new Map<string, string>();
|
||||||
|
private badges: BadgeService | null = null;
|
||||||
|
|
||||||
public setClient(client: Client) {
|
public setClient(client: Client) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setBadgeService(badges: BadgeService) {
|
||||||
|
this.badges = badges;
|
||||||
|
}
|
||||||
|
|
||||||
public startScheduler(intervalMs = 60 * 60 * 1000) {
|
public startScheduler(intervalMs = 60 * 60 * 1000) {
|
||||||
if (this.timer) clearInterval(this.timer);
|
if (this.timer) clearInterval(this.timer);
|
||||||
const interval = Math.max(10 * 60 * 1000, intervalMs);
|
const interval = Math.max(10 * 60 * 1000, intervalMs);
|
||||||
@@ -82,6 +88,7 @@ export class BirthdayService {
|
|||||||
update: { birthDate },
|
update: { birthDate },
|
||||||
create: { guildId, userId, birthDate }
|
create: { guildId, userId, birthDate }
|
||||||
});
|
});
|
||||||
|
await this.badges?.award(guildId, userId, 'birthday_set');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getBirthday(guildId: string, userId: string) {
|
public async getBirthday(guildId: string, userId: string) {
|
||||||
|
|||||||
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',
|
event: 'eventsEnabled',
|
||||||
events: 'eventsEnabled',
|
events: 'eventsEnabled',
|
||||||
// Tasks
|
// Tasks
|
||||||
task: 'tasksEnabled'
|
task: 'tasksEnabled',
|
||||||
|
// Badges
|
||||||
|
badges: 'badgesEnabled',
|
||||||
|
profile: 'badgesEnabled'
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(private client: Client, private admin?: AdminService, private statuspage?: StatuspageService) {}
|
constructor(private client: Client, private admin?: AdminService, private statuspage?: StatuspageService) {}
|
||||||
|
|||||||
@@ -10,11 +10,14 @@ import {
|
|||||||
EmbedBuilder,
|
EmbedBuilder,
|
||||||
ModalBuilder,
|
ModalBuilder,
|
||||||
ModalSubmitInteraction,
|
ModalSubmitInteraction,
|
||||||
|
RoleSelectMenuBuilder,
|
||||||
|
RoleSelectMenuInteraction,
|
||||||
TextInputBuilder,
|
TextInputBuilder,
|
||||||
TextInputStyle
|
TextInputStyle
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
|
import { context } from '../config/context';
|
||||||
|
|
||||||
type ComponentInteraction = ButtonInteraction | ChannelSelectMenuInteraction;
|
type ComponentInteraction = ButtonInteraction | ChannelSelectMenuInteraction | RoleSelectMenuInteraction;
|
||||||
|
|
||||||
interface EmbedDraft {
|
interface EmbedDraft {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
@@ -26,6 +29,7 @@ interface EmbedDraft {
|
|||||||
buttonLabel?: string;
|
buttonLabel?: string;
|
||||||
buttonUrl?: string;
|
buttonUrl?: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
|
pingRoleId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class EmbedBuilderService {
|
export class EmbedBuilderService {
|
||||||
@@ -94,6 +98,12 @@ export class EmbedBuilderService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (interaction.isRoleSelectMenu()) {
|
||||||
|
draft.pingRoleId = interaction.values[0];
|
||||||
|
await interaction.update(this.renderPreview(draft));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const action = interaction.customId.split(':')[1];
|
const action = interaction.customId.split(':')[1];
|
||||||
|
|
||||||
if (action === 'addbutton') {
|
if (action === 'addbutton') {
|
||||||
@@ -127,7 +137,12 @@ export class EmbedBuilderService {
|
|||||||
draft.buttonLabel && draft.buttonUrl
|
draft.buttonLabel && draft.buttonUrl
|
||||||
? [new ActionRowBuilder<ButtonBuilder>().addComponents(new ButtonBuilder().setLabel(draft.buttonLabel).setURL(draft.buttonUrl).setStyle(ButtonStyle.Link))]
|
? [new ActionRowBuilder<ButtonBuilder>().addComponents(new ButtonBuilder().setLabel(draft.buttonLabel).setURL(draft.buttonUrl).setStyle(ButtonStyle.Link))]
|
||||||
: [];
|
: [];
|
||||||
await channel.send({ embeds: [embed], components });
|
await channel.send({
|
||||||
|
content: draft.pingRoleId ? `<@&${draft.pingRoleId}>` : undefined,
|
||||||
|
embeds: [embed],
|
||||||
|
components,
|
||||||
|
allowedMentions: draft.pingRoleId ? { roles: [draft.pingRoleId] } : undefined
|
||||||
|
});
|
||||||
this.drafts.delete(messageId);
|
this.drafts.delete(messageId);
|
||||||
await interaction.update({ content: `Embed gesendet in <#${draft.channelId}>.`, embeds: [], components: [] });
|
await interaction.update({ content: `Embed gesendet in <#${draft.channelId}>.`, embeds: [], components: [] });
|
||||||
return;
|
return;
|
||||||
@@ -139,16 +154,19 @@ export class EmbedBuilderService {
|
|||||||
const channelRow = new ActionRowBuilder<ChannelSelectMenuBuilder>().addComponents(
|
const channelRow = new ActionRowBuilder<ChannelSelectMenuBuilder>().addComponents(
|
||||||
new ChannelSelectMenuBuilder().setCustomId('embed:channel').setChannelTypes(ChannelType.GuildText).setPlaceholder('Zielkanal wählen')
|
new ChannelSelectMenuBuilder().setCustomId('embed:channel').setChannelTypes(ChannelType.GuildText).setPlaceholder('Zielkanal wählen')
|
||||||
);
|
);
|
||||||
|
const roleRow = new ActionRowBuilder<RoleSelectMenuBuilder>().addComponents(
|
||||||
|
new RoleSelectMenuBuilder().setCustomId('embed:role').setPlaceholder('Rolle pingen (optional)')
|
||||||
|
);
|
||||||
const buttonRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
const buttonRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
new ButtonBuilder().setCustomId('embed:addbutton').setLabel(draft.buttonLabel ? 'Link-Button bearbeiten' : 'Link-Button hinzufügen').setStyle(ButtonStyle.Secondary),
|
new ButtonBuilder().setCustomId('embed:addbutton').setLabel(draft.buttonLabel ? 'Link-Button bearbeiten' : 'Link-Button hinzufügen').setStyle(ButtonStyle.Secondary),
|
||||||
new ButtonBuilder().setCustomId('embed:send').setLabel('Senden').setStyle(ButtonStyle.Success).setDisabled(!draft.channelId),
|
new ButtonBuilder().setCustomId('embed:send').setLabel('Senden').setStyle(ButtonStyle.Success).setDisabled(!draft.channelId),
|
||||||
new ButtonBuilder().setCustomId('embed:cancel').setLabel('Abbrechen').setStyle(ButtonStyle.Danger)
|
new ButtonBuilder().setCustomId('embed:cancel').setLabel('Abbrechen').setStyle(ButtonStyle.Danger)
|
||||||
);
|
);
|
||||||
return { embeds: [embed], components: [channelRow, buttonRow] };
|
return { embeds: [embed], components: [channelRow, roleRow, buttonRow] };
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildEmbed(draft: EmbedDraft) {
|
private buildEmbed(draft: EmbedDraft) {
|
||||||
const embed = new EmbedBuilder().setColor(draft.color ?? 0xf97316);
|
const embed = new EmbedBuilder().setColor(draft.color ?? context.branding.getColor(draft.guildId));
|
||||||
if (draft.title) embed.setTitle(draft.title);
|
if (draft.title) embed.setTitle(draft.title);
|
||||||
if (draft.description) embed.setDescription(draft.description);
|
if (draft.description) embed.setDescription(draft.description);
|
||||||
if (draft.imageUrl) embed.setImage(draft.imageUrl);
|
if (draft.imageUrl) embed.setImage(draft.imageUrl);
|
||||||
|
|||||||
@@ -2,17 +2,23 @@ import { Client, EmbedBuilder, TextChannel, ActionRowBuilder, ButtonBuilder, But
|
|||||||
import { prisma } from '../database';
|
import { prisma } from '../database';
|
||||||
import { settingsStore } from '../config/state';
|
import { settingsStore } from '../config/state';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
import { BadgeService } from './badgeService';
|
||||||
|
|
||||||
export type RepeatType = 'none' | 'daily' | 'weekly' | 'monthly';
|
export type RepeatType = 'none' | 'daily' | 'weekly' | 'monthly';
|
||||||
|
|
||||||
export class EventService {
|
export class EventService {
|
||||||
private client: Client | null = null;
|
private client: Client | null = null;
|
||||||
private timer: NodeJS.Timeout | null = null;
|
private timer: NodeJS.Timeout | null = null;
|
||||||
|
private badges: BadgeService | null = null;
|
||||||
|
|
||||||
public setClient(client: Client) {
|
public setClient(client: Client) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setBadgeService(badges: BadgeService) {
|
||||||
|
this.badges = badges;
|
||||||
|
}
|
||||||
|
|
||||||
public startScheduler(intervalMs = 60000) {
|
public startScheduler(intervalMs = 60000) {
|
||||||
if (this.timer) clearInterval(this.timer);
|
if (this.timer) clearInterval(this.timer);
|
||||||
const interval = Math.max(30000, intervalMs);
|
const interval = Math.max(30000, intervalMs);
|
||||||
@@ -135,6 +141,7 @@ export class EventService {
|
|||||||
update: { canceledAt: null },
|
update: { canceledAt: null },
|
||||||
create: { eventId: ev.id, guildId: interaction.guildId, userId: interaction.user.id }
|
create: { eventId: ev.id, guildId: interaction.guildId, userId: interaction.user.id }
|
||||||
});
|
});
|
||||||
|
await this.badges?.award(interaction.guildId, interaction.user.id, 'event_participant');
|
||||||
await interaction.reply({ content: 'Du bist angemeldet.', ephemeral: true });
|
await interaction.reply({ content: 'Du bist angemeldet.', ephemeral: true });
|
||||||
} else {
|
} else {
|
||||||
const existing = await prisma.eventSignup.findFirst({ where: { eventId: ev.id, userId: interaction.user.id, canceledAt: null } });
|
const existing = await prisma.eventSignup.findFirst({ where: { eventId: ev.id, userId: interaction.user.id, canceledAt: null } });
|
||||||
|
|||||||
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 { logger } from '../utils/logger';
|
||||||
import { settingsStore } from '../config/state';
|
import { settingsStore } from '../config/state';
|
||||||
import type { AdminService } from './adminService';
|
import type { AdminService } from './adminService';
|
||||||
|
import { context } from '../config/context';
|
||||||
|
|
||||||
let adminSink: AdminService | null = null;
|
let adminSink: AdminService | null = null;
|
||||||
export const setLoggingAdmin = (admin: AdminService) => {
|
export const setLoggingAdmin = (admin: AdminService) => {
|
||||||
@@ -66,6 +67,7 @@ export class LoggingService {
|
|||||||
.setDescription(`${member.user.tag} ist dem Server beigetreten.`)
|
.setDescription(`${member.user.tag} ist dem Server beigetreten.`)
|
||||||
.setColor(0x00ff99)
|
.setColor(0x00ff99)
|
||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
|
context.branding.applyFooter(embed, member.guild.id);
|
||||||
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log join', err));
|
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log join', err));
|
||||||
adminSink?.pushGuildLog({ guildId: member.guild.id, level: 'INFO', message: 'Member joined: ' + member.user.tag, timestamp: Date.now(), category: 'joinLeave' });
|
adminSink?.pushGuildLog({ guildId: member.guild.id, level: 'INFO', message: 'Member joined: ' + member.user.tag, timestamp: Date.now(), category: 'joinLeave' });
|
||||||
}
|
}
|
||||||
@@ -79,6 +81,7 @@ export class LoggingService {
|
|||||||
.setDescription(`${member.user.tag} hat den Server verlassen.`)
|
.setDescription(`${member.user.tag} hat den Server verlassen.`)
|
||||||
.setColor(0xff9900)
|
.setColor(0xff9900)
|
||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
|
context.branding.applyFooter(embed, member.guild.id);
|
||||||
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log leave', err));
|
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log leave', err));
|
||||||
adminSink?.pushGuildLog({ guildId: member.guild.id, level: 'WARN', message: 'Member left: ' + member.user.tag, timestamp: Date.now(), category: 'joinLeave' });
|
adminSink?.pushGuildLog({ guildId: member.guild.id, level: 'WARN', message: 'Member left: ' + member.user.tag, timestamp: Date.now(), category: 'joinLeave' });
|
||||||
}
|
}
|
||||||
@@ -94,6 +97,7 @@ export class LoggingService {
|
|||||||
.addFields({ name: 'Inhalt', value: this.safeField(message.content) })
|
.addFields({ name: 'Inhalt', value: this.safeField(message.content) })
|
||||||
.setColor(0xff0000)
|
.setColor(0xff0000)
|
||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
|
context.branding.applyFooter(embed, message.guild.id);
|
||||||
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log message delete', err));
|
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log message delete', err));
|
||||||
adminSink?.pushGuildLog({
|
adminSink?.pushGuildLog({
|
||||||
guildId: message.guild.id,
|
guildId: message.guild.id,
|
||||||
@@ -118,6 +122,7 @@ export class LoggingService {
|
|||||||
)
|
)
|
||||||
.setColor(0xffff00)
|
.setColor(0xffff00)
|
||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
|
context.branding.applyFooter(embed, oldMessage.guild.id);
|
||||||
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log message edit', err));
|
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log message edit', err));
|
||||||
adminSink?.pushGuildLog({
|
adminSink?.pushGuildLog({
|
||||||
guildId: oldMessage.guild.id,
|
guildId: oldMessage.guild.id,
|
||||||
@@ -141,6 +146,7 @@ export class LoggingService {
|
|||||||
.addFields({ name: 'Grund', value: this.safeField(reason || 'Nicht angegeben') })
|
.addFields({ name: 'Grund', value: this.safeField(reason || 'Nicht angegeben') })
|
||||||
.setColor(0x7289da)
|
.setColor(0x7289da)
|
||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
|
context.branding.applyFooter(embed, resolvedGuild.id);
|
||||||
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log action', err));
|
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log action', err));
|
||||||
const guildId = resolvedGuild.id;
|
const guildId = resolvedGuild.id;
|
||||||
if (guildId) {
|
if (guildId) {
|
||||||
@@ -174,6 +180,7 @@ export class LoggingService {
|
|||||||
if (options.messageUrl) {
|
if (options.messageUrl) {
|
||||||
embed.addFields({ name: 'Link', value: options.messageUrl });
|
embed.addFields({ name: 'Link', value: options.messageUrl });
|
||||||
}
|
}
|
||||||
|
context.branding.applyFooter(embed, guild.id);
|
||||||
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log automod action', err));
|
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log automod action', err));
|
||||||
adminSink?.pushGuildLog({
|
adminSink?.pushGuildLog({
|
||||||
guildId: guild.id,
|
guildId: guild.id,
|
||||||
@@ -185,6 +192,55 @@ export class LoggingService {
|
|||||||
adminSink?.trackGuildEvent(guild.id, 'automod');
|
adminSink?.trackGuildEvent(guild.id, 'automod');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logWatchlistEvent(guild: Guild, options: { userId: string; userTag?: string; eventLabel: string; detail?: string }) {
|
||||||
|
const { channel } = this.resolve(guild);
|
||||||
|
if (!channel) return;
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('Watchlist-Alarm')
|
||||||
|
.setDescription(`<@${options.userId}> (${options.userTag ?? options.userId}) — ${options.eventLabel}`)
|
||||||
|
.setColor(0xeab308)
|
||||||
|
.setTimestamp();
|
||||||
|
if (options.detail) embed.addFields({ name: 'Details', value: this.safeField(options.detail) });
|
||||||
|
context.branding.applyFooter(embed, guild.id);
|
||||||
|
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log watchlist event', err));
|
||||||
|
adminSink?.pushGuildLog({
|
||||||
|
guildId: guild.id,
|
||||||
|
level: 'WARN',
|
||||||
|
message: `Watchlist: ${options.eventLabel} (${options.userTag ?? options.userId})`,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
category: 'automodActions'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logSuspiciousJoin(member: GuildMember, reasons: string[]) {
|
||||||
|
const { channel } = this.resolve(member.guild);
|
||||||
|
if (!channel) return;
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('⚠️ Neuer Account wirkt verdächtig. Bitte prüfen')
|
||||||
|
.setDescription(`${member.user.tag} (<@${member.id}>)`)
|
||||||
|
.addFields({ name: 'Gründe', value: reasons.map((r) => `• ${r}`).join('\n') })
|
||||||
|
.setColor(0xdc2626)
|
||||||
|
.setTimestamp();
|
||||||
|
context.branding.applyFooter(embed, member.guild.id);
|
||||||
|
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log suspicious join', err));
|
||||||
|
adminSink?.pushGuildLog({
|
||||||
|
guildId: member.guild.id,
|
||||||
|
level: 'WARN',
|
||||||
|
message: `Verdächtiger Join: ${member.user.tag} (${reasons.join(', ')})`,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
category: 'joinLeave'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logPartnerEvent(guild: Guild, message: string) {
|
||||||
|
const { channel } = this.resolve(guild);
|
||||||
|
if (!channel) return;
|
||||||
|
const embed = new EmbedBuilder().setTitle('Partner-System').setDescription(message).setColor(context.branding.getColor(guild.id)).setTimestamp();
|
||||||
|
context.branding.applyFooter(embed, guild.id);
|
||||||
|
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log partner event', err));
|
||||||
|
adminSink?.pushGuildLog({ guildId: guild.id, level: 'INFO', message: `Partner: ${message}`, timestamp: Date.now(), category: 'system' });
|
||||||
|
}
|
||||||
|
|
||||||
logRoleUpdate(member: GuildMember, added: string[], removed: string[]) {
|
logRoleUpdate(member: GuildMember, added: string[], removed: string[]) {
|
||||||
const guildId = member.guild.id;
|
const guildId = member.guild.id;
|
||||||
adminSink?.pushGuildLog({
|
adminSink?.pushGuildLog({
|
||||||
|
|||||||
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'
|
| 'eventsEnabled'
|
||||||
| 'registerEnabled'
|
| 'registerEnabled'
|
||||||
| 'serverStatsEnabled'
|
| 'serverStatsEnabled'
|
||||||
| 'tasksEnabled';
|
| 'tasksEnabled'
|
||||||
|
| 'badgesEnabled';
|
||||||
|
|
||||||
export interface GuildModuleState {
|
export interface GuildModuleState {
|
||||||
key: ModuleKey;
|
key: ModuleKey;
|
||||||
@@ -35,7 +36,8 @@ const MODULES: Record<ModuleKey, { name: string; description: string }> = {
|
|||||||
eventsEnabled: { name: 'Termine', description: 'Events planen, erinnern und Anmeldungen sammeln.' },
|
eventsEnabled: { name: 'Termine', description: 'Events planen, erinnern und Anmeldungen sammeln.' },
|
||||||
registerEnabled: { name: 'Register', description: 'Registrierungsformulare und Bewerbungen.' },
|
registerEnabled: { name: 'Register', description: 'Registrierungsformulare und Bewerbungen.' },
|
||||||
serverStatsEnabled: { name: 'Server Stats', description: 'Zeigt Member-/Channel-Zahlen als Voice-Statistiken an.' },
|
serverStatsEnabled: { name: 'Server Stats', description: 'Zeigt Member-/Channel-Zahlen als Voice-Statistiken an.' },
|
||||||
tasksEnabled: { name: 'Team-Aufgaben', description: 'Aufgaben für das Team erstellen und Status per Buttons pflegen.' }
|
tasksEnabled: { name: 'Team-Aufgaben', description: 'Aufgaben für das Team erstellen und Status per Buttons pflegen.' },
|
||||||
|
badgesEnabled: { name: 'Badges', description: 'Vergibt Abzeichen für Aktivität und Meilensteine.' }
|
||||||
};
|
};
|
||||||
|
|
||||||
export class BotModuleService {
|
export class BotModuleService {
|
||||||
|
|||||||
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 { prisma } from '../database';
|
||||||
import { settingsStore } from '../config/state';
|
import { settingsStore } from '../config/state';
|
||||||
import { env } from '../config/env';
|
import { env } from '../config/env';
|
||||||
|
import { context } from '../config/context';
|
||||||
|
|
||||||
export class RegisterService {
|
export class RegisterService {
|
||||||
private client: Client | null = null;
|
private client: Client | null = null;
|
||||||
@@ -103,7 +104,8 @@ export class RegisterService {
|
|||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle(form.name)
|
.setTitle(form.name)
|
||||||
.setDescription(message || 'Klicke auf Registrieren, um das Formular auszufüllen.')
|
.setDescription(message || 'Klicke auf Registrieren, um das Formular auszufüllen.')
|
||||||
.setColor(0xf97316);
|
.setColor(context.branding.getColor(guildId));
|
||||||
|
context.branding.applyFooter(embed, guildId);
|
||||||
const btn = new ButtonBuilder().setCustomId(`register:form:${form.id}`).setLabel('Registrieren').setStyle(ButtonStyle.Primary);
|
const btn = new ButtonBuilder().setCustomId(`register:form:${form.id}`).setLabel('Registrieren').setStyle(ButtonStyle.Primary);
|
||||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(btn);
|
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(btn);
|
||||||
const sent = await (channel as any).send({ embeds: [embed], components: [row] });
|
const sent = await (channel as any).send({ embeds: [embed], components: [row] });
|
||||||
@@ -205,7 +207,7 @@ export class RegisterService {
|
|||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle(`Registrierung: ${form.name}`)
|
.setTitle(`Registrierung: ${form.name}`)
|
||||||
.setDescription(form.description || '')
|
.setDescription(form.description || '')
|
||||||
.setColor(0xf97316)
|
.setColor(context.branding.getColor(guildId))
|
||||||
.addFields(
|
.addFields(
|
||||||
...fields.map((f) => ({
|
...fields.map((f) => ({
|
||||||
name: f.label,
|
name: f.label,
|
||||||
|
|||||||
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: 'gaming', label: 'Gaming', presetModules: ['welcomeEnabled', 'levelingEnabled', 'musicEnabled', 'dynamicVoiceEnabled', 'eventsEnabled'] },
|
||||||
{ key: 'support', label: 'Support', presetModules: ['ticketsEnabled', 'statuspageEnabled', 'automodEnabled'] },
|
{ key: 'support', label: 'Support', presetModules: ['ticketsEnabled', 'statuspageEnabled', 'automodEnabled'] },
|
||||||
{ key: 'club', label: 'Club', presetModules: ['registerEnabled', 'ticketsEnabled', 'eventsEnabled', 'welcomeEnabled'] },
|
{ key: 'club', label: 'Club', presetModules: ['registerEnabled', 'ticketsEnabled', 'eventsEnabled', 'welcomeEnabled'] },
|
||||||
{ key: 'team', label: 'Team', presetModules: ['ticketsEnabled', 'automodEnabled', 'welcomeEnabled'] }
|
{ key: 'team', label: 'Team', presetModules: ['ticketsEnabled', 'automodEnabled', 'welcomeEnabled'] },
|
||||||
|
{ key: 'roleplay', label: 'Roleplay', presetModules: ['ticketsEnabled', 'registerEnabled', 'eventsEnabled', 'welcomeEnabled'] },
|
||||||
|
{ key: 'creator', label: 'Creator', presetModules: ['welcomeEnabled', 'eventsEnabled', 'reactionRolesEnabled'] }
|
||||||
];
|
];
|
||||||
|
|
||||||
const STEP_COUNT = 7;
|
const STEP_COUNT = 7;
|
||||||
@@ -244,15 +246,21 @@ export class SetupWizardService {
|
|||||||
|
|
||||||
if (state.step === 0) {
|
if (state.step === 0) {
|
||||||
embed.setTitle('Setup – Server-Art').setDescription('Wähle die Art deines Servers, um passende Module vorzuschlagen.');
|
embed.setTitle('Setup – Server-Art').setDescription('Wähle die Art deines Servers, um passende Module vorzuschlagen.');
|
||||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
const typeRows: ActionRowBuilder<ButtonBuilder>[] = [];
|
||||||
...SERVER_TYPES.map((t) =>
|
for (let i = 0; i < SERVER_TYPES.length; i += 5) {
|
||||||
new ButtonBuilder()
|
const chunk = SERVER_TYPES.slice(i, i + 5);
|
||||||
.setCustomId(`setup:type:${t.key}`)
|
typeRows.push(
|
||||||
.setLabel(t.label)
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
.setStyle(state.serverType === t.key ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
...chunk.map((t) =>
|
||||||
)
|
new ButtonBuilder()
|
||||||
);
|
.setCustomId(`setup:type:${t.key}`)
|
||||||
return { embeds: [embed], components: [row, cancelRow] };
|
.setLabel(t.label)
|
||||||
|
.setStyle(state.serverType === t.key ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { embeds: [embed], components: [...typeRows, cancelRow] };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.step === 1) {
|
if (state.step === 1) {
|
||||||
|
|||||||
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()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle(`Ticket: ${topic}`)
|
.setTitle(`Ticket: ${topic}`)
|
||||||
.setDescription('Ein Teammitglied wird sich gleich kümmern. Nutze `/claim`, um den Fall zu übernehmen.')
|
.setDescription('Ein Teammitglied wird sich gleich kümmern. Nutze `/claim`, um den Fall zu übernehmen.')
|
||||||
.setColor(0x7289da);
|
.setColor(context.branding.getColor(guild.id));
|
||||||
|
context.branding.applyFooter(embed, guild.id);
|
||||||
|
|
||||||
const controls = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
const controls = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
new ButtonBuilder().setCustomId('ticket:claim').setLabel('Claim').setStyle(ButtonStyle.Primary),
|
new ButtonBuilder().setCustomId('ticket:claim').setLabel('Claim').setStyle(ButtonStyle.Primary),
|
||||||
@@ -323,6 +324,9 @@ export class TicketService {
|
|||||||
});
|
});
|
||||||
await this.sendTicketCreatedLog(guild, channel, member, topic, nextNumber, supportRoleId);
|
await this.sendTicketCreatedLog(guild, channel, member, topic, nextNumber, supportRoleId);
|
||||||
await this.runAutomations({ ...record });
|
await this.runAutomations({ ...record });
|
||||||
|
const priorTickets = await prisma.ticket.count({ where: { guildId: guild.id, userId: member.id } });
|
||||||
|
if (priorTickets === 1) await context.badges.award(guild.id, member.id, 'first_ticket');
|
||||||
|
context.watchlist.notifyIfWatched(guild, member.id, 'Ticket erstellt', topic);
|
||||||
return record as TicketRecord;
|
return record as TicketRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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,
|
name: guild.name,
|
||||||
icon: guild.icon,
|
icon: guild.icon,
|
||||||
memberCount: guild.memberCount,
|
memberCount: guild.memberCount,
|
||||||
|
boostCount: guild.premiumSubscriptionCount ?? 0,
|
||||||
createdAt: guild.createdAt?.getTime?.() || null,
|
createdAt: guild.createdAt?.getTime?.() || null,
|
||||||
owner: owner ? { id: owner.id, tag: owner.user?.tag } : null,
|
owner: owner ? { id: owner.id, tag: owner.user?.tag } : null,
|
||||||
textCount,
|
textCount,
|
||||||
@@ -92,6 +93,15 @@ router.get('/guild/info', requireAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/growth', requireAuth, async (req, res) => {
|
||||||
|
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||||
|
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||||
|
const guild = context.client?.guilds.cache.get(guildId) || (await context.client?.guilds.fetch(guildId).catch(() => null));
|
||||||
|
if (!guild) return res.status(404).json({ error: 'guild not found' });
|
||||||
|
const stats = await context.growth.getStats(guild);
|
||||||
|
res.json({ stats });
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/guild/activity', requireAuth, (req, res) => {
|
router.get('/guild/activity', requireAuth, (req, res) => {
|
||||||
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||||
@@ -151,9 +161,22 @@ router.get('/overview', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/admin/overview', requireAuth, requireAdmin, (_req, res) => {
|
router.get('/admin/overview', requireAuth, requireAdmin, async (_req, res) => {
|
||||||
const overview = context.admin.getOverview();
|
const overview = context.admin.getOverview();
|
||||||
res.json({ overview });
|
const guildList = Array.from(context.client?.guilds.cache.values() || []).map((g) => ({
|
||||||
|
id: g.id,
|
||||||
|
name: g.name,
|
||||||
|
icon: g.icon,
|
||||||
|
memberCount: g.memberCount,
|
||||||
|
boostCount: g.premiumSubscriptionCount ?? 0
|
||||||
|
}));
|
||||||
|
const [badgesAwarded, openTasks, activeWatchlist, pendingPartners] = await Promise.all([
|
||||||
|
prisma.userBadge.count(),
|
||||||
|
prisma.staffTask.count({ where: { status: { not: 'done' } } }),
|
||||||
|
prisma.watchlist.count({ where: { active: true } }),
|
||||||
|
prisma.partnerRequest.count({ where: { status: 'pending' } })
|
||||||
|
]);
|
||||||
|
res.json({ overview, guildList, usage: { badgesAwarded, openTasks, activeWatchlist, pendingPartners } });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/admin/activity', requireAuth, requireAdmin, (_req, res) => {
|
router.get('/admin/activity', requireAuth, requireAdmin, (_req, res) => {
|
||||||
@@ -856,6 +879,21 @@ router.delete('/automod/strikes', requireAuth, async (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/watchlist', requireAuth, async (req, res) => {
|
||||||
|
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||||
|
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||||
|
const entries = await context.watchlist.list(guildId);
|
||||||
|
res.json({ entries });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/watchlist', requireAuth, async (req, res) => {
|
||||||
|
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||||
|
const userId = typeof req.query.userId === 'string' ? req.query.userId : undefined;
|
||||||
|
if (!guildId || !userId) return res.status(400).json({ error: 'guildId and userId required' });
|
||||||
|
await context.watchlist.remove(guildId, userId);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/tasks', requireAuth, async (req, res) => {
|
router.get('/tasks', requireAuth, async (req, res) => {
|
||||||
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||||
const status = typeof req.query.status === 'string' ? req.query.status : undefined;
|
const status = typeof req.query.status === 'string' ? req.query.status : undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user