Compare commits

..

3 Commits

Author SHA1 Message Date
3ac8d9460d add dashboard pages for permission scanner, info panels, and ticket categories
All checks were successful
Deploy Discord Bot / deploy (push) Successful in -1m11s
SonarQube / sonar (push) Successful in 0s
Bring the command-only permission scanner, info panel builder, and ticket
role/template config to the dashboard. Also fixes /settings silently
dropping brandingConfig/partnerConfig/galleryConfig/imageModerationConfig/
ticketConfig/lockdownConfig on save.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 01:28:18 +02:00
9943254aa4 add permission scanner, info panels, image QR scan, invite tracker, ticket templates
Five new features:
- /permissions scan: audits admin/ban roles, dangerous bot permissions,
  public channels, empty/duplicate/useless roles, too-many-admins warning.
- /panel create: fixed info panels (rules/support/bewerbung/partner/
  rollen/events/faq) with buttons wired into the existing ticket/
  register/partner flows, FAQ panels get a question dropdown.
- Image moderation: QR-code detection in message attachments via jimp+jsqr
  (pure JS, no native build deps) with a small scam-pattern check; opt-in
  toggle in dashboard settings since decoding costs per-message.
- Invite tracker extension: now resolves the inviter (not just the code),
  tracks suspicious joins per invite, and the dashboard growth page gets
  an invite breakdown + recent-joins table.
- Ticket categories: /ticketconfig lets each ticket topic get its own
  ping role and a modal question template shown before the channel is
  created; topics without config behave exactly as before.

Also fixes two rough edges found along the way: the welcome embed had
no way to attach an image despite the backend already supporting it
(added URL/file-upload fields + live preview), and Reaction Roles
required typing raw role IDs into a textarea (replaced with a proper
role picker). Incidentally repairs node_modules/.bin symlinks that were
committed as literal broken shell-script text instead of real symlinks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 00:46:23 +02:00
54376fdad7 unify module-active toggle styling and regroup sidebar navigation
Extracts the accent-card toggle from Automod into a shared
ModuleActiveToggle component and applies it to every module's
"aktiv" switch (Statuspage, Dynamic Voice, Server Stats, Birthday,
Welcome) so they all match instead of the plain switch some pages
had. Also regroups the sidebar: Reaction Roles moves to Community
(it's member self-service, not moderation), and a new "Statistiken"
group splits Statuspage/Server Stats/Wachstum out of the catch-all
"Funktionen" section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 23:48:44 +02:00
60 changed files with 4621 additions and 377 deletions

View File

@@ -22,6 +22,8 @@ import { Watchlist } from './pages/Watchlist';
import { Admin } from './pages/Admin';
import { Branding } from './pages/Branding';
import { Growth } from './pages/Growth';
import { Permissions } from './pages/Permissions';
import { Panels } from './pages/Panels';
const THEME_COLORS: Record<string, string> = {
orange: '#f97316',
@@ -68,6 +70,8 @@ function AppContent() {
case 'watchlist': return <Watchlist />;
case 'branding': return <Branding />;
case 'growth': return <Growth />;
case 'permissions': return <Permissions />;
case 'panels': return <Panels />;
case 'admin': return <Admin />;
default: return <Dashboard />;
}

View File

@@ -6,7 +6,7 @@ import {
import {
LogOut, PanelLeftClose, PanelLeft, Activity, AudioLines, CalendarDays,
ClipboardList, Eye, Home, LogIn, ListChecks, Music, Palette, Puzzle, RadioTower, Settings,
Shield, Sparkles, Tag, Ticket, TrendingUp, Wrench
Shield, ShieldAlert, Sparkles, Tag, Ticket, TrendingUp, LayoutPanelTop, Wrench
} from 'lucide-react';
import { useApp } from '../../context/AppContext';
import { AppAvatar } from '../shared/AppAvatar';
@@ -24,6 +24,8 @@ const navGroups = [
{ key: 'welcome', label: 'Willkommen', icon: <Sparkles size={18} /> },
{ key: 'birthday', label: 'Birthday', icon: <CalendarDays size={18} /> },
{ key: 'events', label: 'Events', icon: <Activity size={18} /> },
{ key: 'reactionroles', label: 'Reaction Roles', icon: <Tag size={18} /> },
{ key: 'panels', label: 'Info-Panels', icon: <LayoutPanelTop size={18} /> },
]
},
{
@@ -38,7 +40,7 @@ const navGroups = [
label: 'Moderation',
items: [
{ key: 'automod', label: 'Automod', icon: <Shield size={18} /> },
{ key: 'reactionroles', label: 'Reaction Roles', icon: <Tag size={18} /> },
{ key: 'permissions', label: 'Rechte-Scanner', icon: <ShieldAlert size={18} /> },
{ key: 'tasks', label: 'Team-Aufgaben', icon: <ListChecks size={18} /> },
{ key: 'watchlist', label: 'Watchlist', icon: <Eye size={18} /> },
]
@@ -48,6 +50,11 @@ const navGroups = [
items: [
{ key: 'dynamicvoice', label: 'Dynamic Voice', icon: <AudioLines size={18} /> },
{ key: 'music', label: 'Musik', icon: <Music size={18} /> },
]
},
{
label: 'Statistiken',
items: [
{ key: 'statuspage', label: 'Statuspage', icon: <RadioTower size={18} /> },
{ key: 'serverstats', label: 'Server Stats', icon: <Activity size={18} /> },
{ key: 'growth', label: 'Wachstum', icon: <TrendingUp size={18} /> },

View File

@@ -6,13 +6,14 @@ type Props = {
title?: string;
description?: string;
footer?: string;
image?: string;
accentColor?: string;
children?: ReactNode;
};
const now = () => new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
export function DiscordPreview({ botName = 'Papo', title, description, footer, accentColor = '#5865f2', children }: Props) {
export function DiscordPreview({ botName = 'Papo', title, description, footer, image, accentColor = '#5865f2', children }: Props) {
return (
<div className="rounded-lg bg-[#313338] p-4">
<div className="flex gap-3">
@@ -26,7 +27,7 @@ export function DiscordPreview({ botName = 'Papo', title, description, footer, a
<span className="text-xs text-[#949ba4]">Heute um {now()}</span>
</div>
{(title || description || footer) && (
{(title || description || footer || image) && (
<div
className="mt-1 max-w-md rounded border-l-4 bg-[#2b2d31] p-3"
style={{ borderLeftColor: accentColor }}
@@ -36,6 +37,7 @@ export function DiscordPreview({ botName = 'Papo', title, description, footer, a
<div className="mt-1 whitespace-pre-wrap text-sm leading-snug text-[#dbdee1]">{description}</div>
)}
{footer && <div className="mt-2.5 text-xs text-[#949ba4]">{footer}</div>}
{image && <img src={image} alt="" className="mt-2.5 max-h-64 w-full max-w-full rounded object-cover" />}
</div>
)}

View File

@@ -0,0 +1,25 @@
import type { ReactNode } from 'react';
import { AppSwitch } from './AppSwitch';
type Props = {
icon: ReactNode;
title: string;
description: string;
isSelected: boolean;
onChange: (value: boolean) => void;
};
export function ModuleActiveToggle({ icon, title, description, isSelected, onChange }: Props) {
return (
<div className="flex items-center gap-3 rounded-xl border border-accent bg-accent-soft p-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-accent text-accent-foreground">
{icon}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold">{title}</div>
<div className="text-xs text-muted">{description}</div>
</div>
<AppSwitch aria-label={title} isSelected={isSelected} onChange={onChange} />
</div>
);
}

View File

@@ -4,7 +4,9 @@ import type {
AppConfig, User, Guild, NavKey, TicketRecord, StatusService,
EventItem, ReactionRoleSet, ModuleItem, LogEntry, SettingsState,
SupportLoginConfig, SupportLoginStatus, RegisterForm, RegisterFormField,
RegisterApplication, MusicSession, StaffTask, WatchlistEntry, GrowthStats
RegisterApplication, MusicSession, StaffTask, WatchlistEntry, GrowthStats,
InviteBreakdownEntry, RecentJoinEntry, PermissionScanResult, InfoPanel, InfoPanelType,
TicketTopicConfig
} from '../types';
const appConfig: AppConfig = (window as any).__PAPO__ || {};
@@ -48,6 +50,14 @@ type AppState = {
taskDraft: { title: string; description: string };
watchlistEntries: WatchlistEntry[];
growthStats: GrowthStats | null;
inviteBreakdown: InviteBreakdownEntry[];
recentJoins: RecentJoinEntry[];
permissionScan: PermissionScanResult | null;
permissionScanLoading: boolean;
infoPanels: InfoPanel[];
panelDraft: { type: InfoPanelType; channelId: string; title: string; description: string; items: string };
ticketTopics: Record<string, TicketTopicConfig>;
ticketTopicDraft: { topic: string; roleId: string; questions: string };
};
type AppContextType = AppState & {
@@ -131,6 +141,15 @@ type AppContextType = AppState & {
loadWatchlist: () => Promise<void>;
removeFromWatchlist: (userId: string) => Promise<void>;
loadGrowthStats: () => Promise<void>;
loadInviteBreakdown: () => Promise<void>;
loadPermissionScan: () => Promise<void>;
loadInfoPanels: () => Promise<void>;
setPanelDraft: (s: any | ((prev: any) => any)) => void;
createInfoPanel: () => Promise<void>;
deleteInfoPanel: (id: string) => Promise<void>;
loadTicketTopics: () => Promise<void>;
setTicketTopicDraft: (s: any | ((prev: any) => any)) => void;
saveTicketTopic: () => Promise<void>;
};
const AppContext = createContext<AppContextType | null>(null);
@@ -165,7 +184,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
const [eventDraft, setEventDraft] = useState({ title: '', description: '', channelId: '', startsAt: '' });
const [statusDraft, setStatusDraft] = useState<any>(null);
const [statsDraft, setStatsDraft] = useState<any>(null);
const [reactionDraft, setReactionDraft] = useState({ title: '', channelId: '', entries: '' });
const [reactionDraft, setReactionDraft] = useState<{ title: string; channelId: string; entries: { emoji: string; roleId: string; label: string; description: string }[] }>({ title: '', channelId: '', entries: [] });
const [supportLogin, setSupportLogin] = useState<{ config: SupportLoginConfig; status: SupportLoginStatus; supportRoleId?: string } | null>(null);
const [registerForms, setRegisterForms] = useState<RegisterForm[]>([]);
const [registerApps, setRegisterApps] = useState<RegisterApplication[]>([]);
@@ -190,6 +209,14 @@ export function AppProvider({ children }: { children: ReactNode }) {
const [taskDraft, setTaskDraft] = useState({ title: '', description: '' });
const [watchlistEntries, setWatchlistEntries] = useState<WatchlistEntry[]>([]);
const [growthStats, setGrowthStats] = useState<GrowthStats | null>(null);
const [inviteBreakdown, setInviteBreakdown] = useState<InviteBreakdownEntry[]>([]);
const [recentJoins, setRecentJoins] = useState<RecentJoinEntry[]>([]);
const [permissionScan, setPermissionScan] = useState<PermissionScanResult | null>(null);
const [permissionScanLoading, setPermissionScanLoading] = useState(false);
const [infoPanels, setInfoPanels] = useState<InfoPanel[]>([]);
const [panelDraft, setPanelDraft] = useState<{ type: InfoPanelType; channelId: string; title: string; description: string; items: string }>({ type: 'rules', channelId: '', title: '', description: '', items: '' });
const [ticketTopics, setTicketTopics] = useState<Record<string, TicketTopicConfig>>({});
const [ticketTopicDraft, setTicketTopicDraft] = useState({ topic: '', roleId: '', questions: '' });
const setSection = useCallback((key: NavKey) => {
setSectionState(key);
@@ -198,7 +225,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const hash = window.location.hash.replace('#', '') as NavKey;
const validKeys: NavKey[] = ['overview', 'tickets', 'supportlogin', 'automod', 'welcome', 'dynamicvoice', 'birthday', 'reactionroles', 'statuspage', 'serverstats', 'register', 'music', 'settings', 'modules', 'events', 'admin'];
const validKeys: NavKey[] = ['overview', 'tickets', 'supportlogin', 'automod', 'welcome', 'dynamicvoice', 'birthday', 'reactionroles', 'statuspage', 'serverstats', 'register', 'music', 'settings', 'modules', 'events', 'tasks', 'watchlist', 'branding', 'growth', 'permissions', 'panels', 'admin'];
if (validKeys.includes(hash)) setSectionState(hash);
}, []);
@@ -277,7 +304,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
setRegisterApps(registerAppsRes.applications || []);
setTasks(tasksRes.tasks || []);
setWatchlistEntries(watchlistRes.entries || []);
setReactionDraft({ title: '', channelId: '', entries: '' });
setReactionDraft({ title: '', channelId: '', entries: [] });
await Promise.all([loadTicketData(guildId), loadAdminData()]);
setStatusMessage('');
} catch { setStatusMessage('Daten konnten nicht geladen werden'); }
@@ -356,11 +383,10 @@ export function AppProvider({ children }: { children: ReactNode }) {
}
async function saveReactionRole() {
const entries = reactionDraft.entries.split('\n').map((l) => l.trim()).filter(Boolean)
.map((line) => { const p = line.split('|').map((s) => s.trim()); return { emoji: p[0], roleId: p[1], label: p[2], description: p[3] }; })
.filter((e) => e.emoji && e.roleId);
const entries = (reactionDraft.entries as any[]).filter((e) => e.emoji && e.roleId);
await apiFetch('/reactionroles', { method: 'POST', body: JSON.stringify({ guildId: currentGuildId, channelId: reactionDraft.channelId, title: reactionDraft.title, entries }) });
await loadGuildData(currentGuildId);
setReactionDraft({ title: '', channelId: '', entries: [] });
setStatusMessage('Reaction Role gespeichert');
}
@@ -606,6 +632,71 @@ export function AppProvider({ children }: { children: ReactNode }) {
setGrowthStats(res.stats || null);
}
async function loadInviteBreakdown() {
if (!currentGuildId) return;
const res = await apiFetch<any>(`/growth/invites?guildId=${encodeURIComponent(currentGuildId)}`);
setInviteBreakdown(res.breakdown || []);
setRecentJoins(res.recentJoins || []);
}
async function loadPermissionScan() {
if (!currentGuildId) return;
setPermissionScanLoading(true);
try {
const res = await apiFetch<any>(`/permissions/scan?guildId=${encodeURIComponent(currentGuildId)}`);
setPermissionScan(res.result || null);
} finally {
setPermissionScanLoading(false);
}
}
async function loadInfoPanels() {
if (!currentGuildId) return;
const res = await apiFetch<any>(`/panels?guildId=${encodeURIComponent(currentGuildId)}`);
setInfoPanels(res.panels || []);
}
async function createInfoPanel() {
if (!currentGuildId || !panelDraft.channelId || !panelDraft.title) return;
const items = panelDraft.type === 'faq'
? panelDraft.items.split('\n').map((l) => l.trim()).filter(Boolean).slice(0, 25).map((line) => {
const [q, ...rest] = line.split(':');
return { question: (q || '').trim(), answer: rest.join(':').trim() || 'Keine Antwort hinterlegt.' };
}).filter((i) => i.question)
: undefined;
await apiFetch('/panels', {
method: 'POST',
body: JSON.stringify({ guildId: currentGuildId, channelId: panelDraft.channelId, type: panelDraft.type, title: panelDraft.title, description: panelDraft.description || undefined, items })
});
setPanelDraft({ type: 'rules', channelId: '', title: '', description: '', items: '' });
setStatusMessage('Panel wurde gepostet');
await loadInfoPanels();
}
async function deleteInfoPanel(id: string) {
await apiFetch(`/panels/${id}?guildId=${encodeURIComponent(currentGuildId)}`, { method: 'DELETE' });
setStatusMessage('Panel gelöscht');
await loadInfoPanels();
}
async function loadTicketTopics() {
if (!currentGuildId) return;
const res = await apiFetch<any>(`/ticketconfig?guildId=${encodeURIComponent(currentGuildId)}`);
setTicketTopics(res.topics || {});
}
async function saveTicketTopic() {
if (!currentGuildId || !ticketTopicDraft.topic.trim()) return;
const questions = ticketTopicDraft.questions.split('\n').map((q) => q.trim()).filter(Boolean).slice(0, 5);
await apiFetch('/ticketconfig', {
method: 'POST',
body: JSON.stringify({ guildId: currentGuildId, topic: ticketTopicDraft.topic.trim().toLowerCase(), roleId: ticketTopicDraft.roleId || undefined, questions })
});
setTicketTopicDraft({ topic: '', roleId: '', questions: '' });
setStatusMessage('Ticket-Kategorie gespeichert');
await loadTicketTopics();
}
const handleLogout = useCallback(() => {
window.location.href = `${appConfig.baseAuth || '/auth'}/logout`;
}, []);
@@ -621,6 +712,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
ticketDetail, ticketMessages, kbEditDraft, automationEditDraft,
automodStrikes, registerStatusFilter, registerFormFilter, selectedAppId,
appNotes, appHistory, noteDraft, tasks, taskDraft, watchlistEntries, growthStats,
inviteBreakdown, recentJoins, permissionScan, permissionScanLoading, infoPanels,
panelDraft, ticketTopics, ticketTopicDraft,
setCurrentGuildId, setSection, setSettings, setBirthday, setSupportLogin,
setStatusDraft, setStatsDraft, setStatusMessage, loadGuildData,
saveSettingsPayload, saveBirthday, saveStatuspage, saveServerStats,
@@ -636,7 +729,9 @@ export function AppProvider({ children }: { children: ReactNode }) {
loadAutomodStrikes, resetAutomodStrike, setRegisterStatusFilter, setRegisterFormFilter,
loadRegisterApps, openAppDetail, setNoteDraft, addAppNote,
setTaskDraft, createTask, updateTaskStatus, deleteTask,
loadWatchlist, removeFromWatchlist, loadGrowthStats,
loadWatchlist, removeFromWatchlist, loadGrowthStats, loadInviteBreakdown,
loadPermissionScan, loadInfoPanels, setPanelDraft, createInfoPanel, deleteInfoPanel,
loadTicketTopics, setTicketTopicDraft, saveTicketTopic,
}}>
{children}
</AppContext.Provider>

View File

@@ -6,6 +6,7 @@ import { SectionCard } from '../components/shared/SectionCard';
import { ChannelSelect, type ChannelOption } from '../components/shared/ChannelSelect';
import { RoleSelect, type RoleOption } from '../components/shared/RoleSelect';
import { AppSwitch } from '../components/shared/AppSwitch';
import { ModuleActiveToggle } from '../components/shared/ModuleActiveToggle';
import { useGuildResources } from '../hooks/useGuildResources';
const FILTERS = [
@@ -206,20 +207,13 @@ export function Automod() {
<h3 className="text-base font-semibold">Automod</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<div className="flex items-center gap-3 rounded-xl border border-accent bg-accent-soft p-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-accent text-accent-foreground">
<Shield size={16} />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold">Automod aktiv</div>
<div className="text-xs text-muted">Schaltet alle Filter unten gesammelt ein oder aus.</div>
</div>
<AppSwitch
aria-label="Automod aktiv"
isSelected={settings.automodEnabled !== false}
onChange={(v) => setSettings((s) => ({ ...s, automodEnabled: v }))}
/>
</div>
<ModuleActiveToggle
icon={<Shield size={16} />}
title="Automod aktiv"
description="Schaltet alle Filter unten gesammelt ein oder aus."
isSelected={settings.automodEnabled !== false}
onChange={(v) => setSettings((s) => ({ ...s, automodEnabled: v }))}
/>
<div className="flex flex-col gap-2">
{FILTERS.map((f) => {

View File

@@ -3,7 +3,7 @@ import { CalendarDays, Save, Cake, Clock } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { AppSwitch } from '../components/shared/AppSwitch';
import { ModuleActiveToggle } from '../components/shared/ModuleActiveToggle';
import { useGuildResources } from '../hooks/useGuildResources';
export function Birthday() {
@@ -18,10 +18,12 @@ export function Birthday() {
<h3 className="text-base font-semibold">Konfiguration</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<AppSwitch
<ModuleActiveToggle
icon={<Cake size={16} />}
title="Birthday aktiv"
description="Speichert Geburtstage und sendet automatisch Glückwünsche."
isSelected={birthday.config?.enabled !== false}
onChange={(v) => setBirthday((s) => ({ ...s, config: { ...s.config, enabled: v } }))}
label={<div className="flex items-center gap-2"><Cake size={16} /> Birthday aktiv</div>}
/>
<TextField>

View File

@@ -3,7 +3,7 @@ import { AudioLines, Save, Mic, Users } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { AppSwitch } from '../components/shared/AppSwitch';
import { ModuleActiveToggle } from '../components/shared/ModuleActiveToggle';
import { useGuildResources } from '../hooks/useGuildResources';
export function DynamicVoice() {
@@ -18,10 +18,12 @@ export function DynamicVoice() {
<h3 className="text-base font-semibold">Konfiguration</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<AppSwitch
<ModuleActiveToggle
icon={<AudioLines size={16} />}
title="Dynamic Voice aktiv"
description="Erstellt private Voice-Channels aus der konfigurierten Lobby."
isSelected={settings.dynamicVoiceEnabled !== false}
onChange={(v) => setSettings((s) => ({ ...s, dynamicVoiceEnabled: v }))}
label={<div className="flex items-center gap-2"><AudioLines size={16} /> Dynamic Voice aktiv</div>}
/>
<TextField>

View File

@@ -1,16 +1,20 @@
import { useEffect } from 'react';
import { Card, CardContent, CardHeader } from '@heroui/react';
import { TrendingUp, TrendingDown, Gem, Link2, Handshake } from 'lucide-react';
import { Card, CardContent, CardHeader, Chip } from '@heroui/react';
import { TrendingUp, TrendingDown, Gem, Link2, Handshake, Users } 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';
import { formatDate } from '../utils/formatters';
export function Growth() {
const { currentGuildId, growthStats, loadGrowthStats } = useApp();
const { currentGuildId, growthStats, loadGrowthStats, inviteBreakdown, recentJoins, loadInviteBreakdown } = useApp();
useEffect(() => {
if (currentGuildId) loadGrowthStats();
if (currentGuildId) {
loadGrowthStats();
loadInviteBreakdown();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentGuildId]);
@@ -71,6 +75,64 @@ export function Growth() {
</CardContent>
</Card>
</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">Invite-Übersicht (30 Tage)</h3>
</CardHeader>
<CardContent className="flex flex-col gap-2 p-5">
{inviteBreakdown.length ? inviteBreakdown.map((inv) => (
<div key={inv.code} className="bg-surface-tertiary flex items-center justify-between gap-2 rounded-xl px-4 py-3 text-sm">
<div className="min-w-0">
<div className="font-medium truncate">discord.gg/{inv.code}</div>
<div className="text-xs text-muted truncate">
{inv.inviterId ? `von <@${inv.inviterId}>` : 'Unbekannter Ersteller'} · {inv.uses} Nutzungen gesamt
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Chip size="sm" variant="soft" color="accent">{inv.joins30} Joins</Chip>
{inv.suspiciousJoins30 > 0 && (
<Chip size="sm" variant="soft" color="danger">{inv.suspiciousJoins30} auffällig</Chip>
)}
</div>
</div>
)) : (
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
<Link2 size={20} />
Keine Invite-Nutzung in den letzten 30 Tagen
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Letzte Beitritte</h3>
</CardHeader>
<CardContent className="flex max-h-96 flex-col gap-2 overflow-y-auto p-5">
{recentJoins.length ? recentJoins.map((j) => (
<div key={j.id} className="bg-surface-tertiary flex items-center gap-3 rounded-xl px-4 py-3 text-sm">
<Users size={14} className="text-muted shrink-0" />
<div className="min-w-0 flex-1">
<div className="truncate">
User-ID {j.userId}{j.inviterId ? ` · eingeladen von ${j.inviterId}` : ''}
</div>
<div className="text-xs text-muted">
{j.inviteCode ? `discord.gg/${j.inviteCode}` : 'Unbekannter Invite'} · {formatDate(j.createdAt)}
</div>
</div>
{j.suspicious && <Chip size="sm" variant="soft" color="danger">Verdächtig</Chip>}
</div>
)) : (
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
<Users size={20} />
Noch keine Beitritte erfasst
</div>
)}
</CardContent>
</Card>
</div>
</SectionCard>
);
}

View File

@@ -0,0 +1,128 @@
import { Card, CardContent, CardHeader, Chip, Button, Input, TextArea, Separator, TextField, Label } from '@heroui/react';
import { LayoutPanelTop, Trash2, Send, ScrollText, LifeBuoy, FileSignature, Handshake, Tags, CalendarDays, CircleHelp } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { useGuildResources } from '../hooks/useGuildResources';
import { SectionCard } from '../components/shared/SectionCard';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { EmptyState } from '../components/shared/EmptyState';
import type { InfoPanelType } from '../types';
import { useEffect } from 'react';
const TYPE_META: Record<InfoPanelType, { label: string; icon: React.ReactNode }> = {
rules: { label: 'Regeln', icon: <ScrollText size={14} /> },
support: { label: 'Support', icon: <LifeBuoy size={14} /> },
bewerbung: { label: 'Bewerbungen', icon: <FileSignature size={14} /> },
partner: { label: 'Partner', icon: <Handshake size={14} /> },
rollen: { label: 'Rollen', icon: <Tags size={14} /> },
events: { label: 'Events', icon: <CalendarDays size={14} /> },
faq: { label: 'FAQ', icon: <CircleHelp size={14} /> },
};
export function Panels() {
const { currentGuildId, infoPanels, loadInfoPanels, panelDraft, setPanelDraft, createInfoPanel, deleteInfoPanel } = useApp();
const { channels } = useGuildResources(currentGuildId);
useEffect(() => {
if (currentGuildId) loadInfoPanels();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentGuildId]);
return (
<SectionCard title="Info-Panels" subtitle="Feste Panels für Regeln, Support, Bewerbungen, Partner, Rollen, Events und FAQ mit passenden Buttons.">
<div className="grid gap-5 xl:grid-cols-[1fr_420px]">
<div>
<h3 className="mb-3 text-base font-semibold">Bestehende Panels ({infoPanels.length})</h3>
<div className="space-y-3">
{infoPanels.length ? infoPanels.map((p) => (
<Card key={p.id}>
<CardContent className="flex items-center justify-between gap-3 p-4">
<div className="flex min-w-0 items-center gap-3">
<div className="bg-accent-soft text-accent-soft-foreground flex size-9 shrink-0 items-center justify-center rounded-lg">
{TYPE_META[p.type]?.icon || <LayoutPanelTop size={14} />}
</div>
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{p.title}</div>
<div className="text-xs text-muted">#{channels.find((c) => c.id === p.channelId)?.name || p.channelId}</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Chip size="sm" variant="soft">{TYPE_META[p.type]?.label || p.type}</Chip>
<Button size="sm" variant="danger-soft" onPress={() => deleteInfoPanel(p.id)}>
<Trash2 size={14} />
</Button>
</div>
</CardContent>
</Card>
)) : <EmptyState message="Noch keine Panels erstellt" icon={<LayoutPanelTop size={24} />} />}
</div>
</div>
<div>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Neues Panel erstellen</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<TextField>
<Label>Typ</Label>
<select
aria-label="Panel-Typ"
className="w-full rounded-xl text-sm"
value={panelDraft.type}
onChange={(e) => setPanelDraft((s: any) => ({ ...s, type: e.target.value as InfoPanelType }))}
>
{Object.entries(TYPE_META).map(([key, meta]) => (
<option key={key} value={key}>{meta.label}</option>
))}
</select>
</TextField>
<TextField>
<Label>Ziel-Kanal</Label>
<ChannelSelect options={channels} value={panelDraft.channelId} onChange={(id) => setPanelDraft((s: any) => ({ ...s, channelId: id }))} />
</TextField>
<TextField>
<Label>Titel</Label>
<Input value={panelDraft.title} onChange={(e) => setPanelDraft((s: any) => ({ ...s, title: e.target.value }))} />
</TextField>
{panelDraft.type !== 'faq' && (
<TextField>
<Label>Beschreibung</Label>
<TextArea rows={4} value={panelDraft.description} onChange={(e) => setPanelDraft((s: any) => ({ ...s, description: e.target.value }))} />
</TextField>
)}
{panelDraft.type === 'faq' && (
<TextField>
<Label>Fragen (eine Frage: Antwort pro Zeile)</Label>
<TextArea
rows={5}
placeholder="Wie erstelle ich ein Ticket?: Klicke auf den Support-Button."
value={panelDraft.items}
onChange={(e) => setPanelDraft((s: any) => ({ ...s, items: e.target.value }))}
/>
</TextField>
)}
<Separator />
<p className="text-xs text-muted">
{panelDraft.type === 'support' && 'Erhält automatisch einen Ticket-Button.'}
{panelDraft.type === 'bewerbung' && 'Erhält automatisch einen Bewerben-Button, falls ein aktives Formular existiert.'}
{panelDraft.type === 'partner' && 'Erhält automatisch einen Partner-werden-Button.'}
{panelDraft.type === 'faq' && 'Erhält ein Dropdown mit den hinterlegten Fragen.'}
{['rules', 'rollen', 'events'].includes(panelDraft.type) && 'Reines Info-Embed ohne Button.'}
</p>
<Button variant="primary" onPress={createInfoPanel} isDisabled={!panelDraft.channelId || !panelDraft.title}>
<Send size={16} /> Panel posten
</Button>
</CardContent>
</Card>
</div>
</div>
</SectionCard>
);
}

View File

@@ -0,0 +1,125 @@
import { useEffect } from 'react';
import { Card, CardContent, CardHeader, Chip, Button } from '@heroui/react';
import { ShieldAlert, ShieldX, Bot, Globe, MessageSquare, Users, Copy, Ban, RefreshCw, TriangleAlert } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { StatCard } from '../components/shared/StatCard';
import { EmptyState } from '../components/shared/EmptyState';
function EntryList({ entries, emptyMessage, icon }: { entries: { id: string; name: string }[]; emptyMessage: string; icon: React.ReactNode }) {
if (!entries.length) return <p className="py-3 text-center text-xs text-muted">{emptyMessage}</p>;
return (
<div className="flex flex-col gap-1.5">
{entries.slice(0, 10).map((e) => (
<div key={e.id} className="bg-surface-tertiary flex items-center gap-2 rounded-lg px-3 py-2 text-sm">
{icon}
<span className="truncate">{e.name}</span>
</div>
))}
{entries.length > 10 && <p className="pl-1 text-xs text-muted">+{entries.length - 10} weitere</p>}
</div>
);
}
export function Permissions() {
const { currentGuildId, permissionScan, permissionScanLoading, loadPermissionScan } = useApp();
useEffect(() => {
if (currentGuildId) loadPermissionScan();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentGuildId]);
const r = permissionScan;
return (
<SectionCard
title="Rechte-Scanner"
subtitle="Sicherheitsanalyse der Server-Rollen, Rechte und Kanäle."
action={
<Button variant="primary" onPress={() => loadPermissionScan()} isDisabled={permissionScanLoading}>
<RefreshCw size={16} className={permissionScanLoading ? 'animate-spin' : ''} /> Scan starten
</Button>
}
>
{!r && !permissionScanLoading && (
<EmptyState message="Noch kein Scan durchgeführt. Klicke auf „Scan starten“." icon={<ShieldAlert size={24} />} />
)}
{r && (
<div className="flex flex-col gap-5">
{r.tooManyAdmins && (
<Card className="border-danger bg-danger-soft">
<CardContent className="flex items-center gap-3 p-4 text-sm">
<TriangleAlert size={18} className="shrink-0 text-danger" />
<span>Auffällig viele Administrator-Rechte vergeben ({r.adminRoles.length} Rollen, {r.adminMemberCount} Mitglieder mit Adminrechten). Prüfe, ob das notwendig ist.</span>
</CardContent>
</Card>
)}
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard icon={<ShieldAlert size={18} />} label="Admin-Rollen" value={r.adminRoles.length} color={r.tooManyAdmins ? 'danger' : 'default'} />
<StatCard icon={<Ban size={18} />} label="Ban-Rollen" value={r.banRoles.length} />
<StatCard icon={<Bot size={18} />} label="Bots mit gefährlichen Rechten" value={r.dangerousBots.length} color={r.dangerousBots.length ? 'warning' : 'default'} />
<StatCard icon={<Globe size={18} />} label="Öffentliche Kanäle" value={r.publicChannels.length} />
</div>
<div className="grid gap-5 xl:grid-cols-2">
<Card>
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Admin-Rollen</h3></CardHeader>
<CardContent className="p-5"><EntryList entries={r.adminRoles} emptyMessage="Keine Admin-Rollen" icon={<ShieldAlert size={14} className="text-danger shrink-0" />} /></CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Rollen, die bannen können</h3></CardHeader>
<CardContent className="p-5"><EntryList entries={r.banRoles} emptyMessage="Keine Ban-Rollen" icon={<Ban size={14} className="text-warning shrink-0" />} /></CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Bots mit gefährlichen Rechten</h3></CardHeader>
<CardContent className="p-5">
{r.dangerousBots.length ? (
<div className="flex flex-col gap-1.5">
{r.dangerousBots.slice(0, 10).map((b) => (
<div key={b.id} className="bg-surface-tertiary flex flex-col gap-1 rounded-lg px-3 py-2 text-sm">
<div className="flex items-center gap-2"><Bot size={14} className="text-muted shrink-0" /><span className="truncate font-medium">{b.tag}</span></div>
<div className="flex flex-wrap gap-1 pl-5">
{b.perms.map((p) => <Chip key={p} size="sm" variant="soft" color="warning">{p}</Chip>)}
</div>
</div>
))}
</div>
) : <p className="py-3 text-center text-xs text-muted">Keine Bots mit gefährlichen Rechten</p>}
</CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">@everyone kann schreiben</h3></CardHeader>
<CardContent className="p-5"><EntryList entries={r.everyoneCanSendChannels} emptyMessage="Kein Kanal betroffen" icon={<MessageSquare size={14} className="text-muted shrink-0" />} /></CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Leere Rollen</h3></CardHeader>
<CardContent className="p-5"><EntryList entries={r.emptyRoles} emptyMessage="Keine leeren Rollen" icon={<Users size={14} className="text-muted shrink-0" />} /></CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Nutzlose Rollen</h3></CardHeader>
<CardContent className="p-5"><EntryList entries={r.uselessRoles} emptyMessage="Keine nutzlosen Rollen" icon={<ShieldX size={14} className="text-muted shrink-0" />} /></CardContent>
</Card>
<Card className="xl:col-span-2">
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Doppelte Rollen (identische Rechte)</h3></CardHeader>
<CardContent className="flex flex-col gap-2 p-5">
{r.duplicateRoleGroups.length ? r.duplicateRoleGroups.map((group, i) => (
<div key={i} className="bg-surface-tertiary flex flex-wrap items-center gap-2 rounded-lg px-3 py-2 text-sm">
<Copy size={14} className="text-muted shrink-0" />
{group.map((role) => <Chip key={role.id} size="sm" variant="soft">{role.name}</Chip>)}
</div>
)) : <p className="py-3 text-center text-xs text-muted">Keine doppelten Rollen gefunden</p>}
</CardContent>
</Card>
</div>
</div>
)}
</SectionCard>
);
}

View File

@@ -1,13 +1,29 @@
import { Card, CardContent, CardHeader, Input, TextArea, Button, Chip, Separator, TextField, Label } from '@heroui/react';
import { Tag, Save, Hash, List } from 'lucide-react';
import { useState } from 'react';
import { Card, CardContent, CardHeader, Input, Button, Chip, TextField, Label } from '@heroui/react';
import { Tag, Save, Plus, X } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { RoleSelect } from '../components/shared/RoleSelect';
import { useGuildResources } from '../hooks/useGuildResources';
export function ReactionRoles() {
const { reactionRoles, reactionDraft, setReactionDraft, saveReactionRole, currentGuildId } = useApp();
const { channels } = useGuildResources(currentGuildId);
const { channels, roles } = useGuildResources(currentGuildId);
const [entryDraft, setEntryDraft] = useState({ emoji: '', roleId: '', label: '' });
const addEntry = () => {
if (!entryDraft.emoji.trim() || !entryDraft.roleId) return;
setReactionDraft((s) => ({
...s,
entries: [...s.entries, { emoji: entryDraft.emoji.trim(), roleId: entryDraft.roleId, label: entryDraft.label.trim(), description: '' }]
}));
setEntryDraft({ emoji: '', roleId: '', label: '' });
};
const removeEntry = (index: number) => {
setReactionDraft((s) => ({ ...s, entries: s.entries.filter((_: any, i: number) => i !== index) }));
};
return (
<SectionCard title="Reaction Roles" subtitle="Sets anzeigen und neue Zuordnungen anlegen">
@@ -61,20 +77,56 @@ export function ReactionRoles() {
/>
</TextField>
<TextField>
<div>
<Label>Einträge</Label>
<TextArea
placeholder="Emoji | Role ID | Label&#10;:emoji: | 123456789 | Rolle 1&#10;:wave: | 987654321 | Rolle 2"
rows={6}
value={reactionDraft.entries}
onChange={(e) => setReactionDraft((s) => ({ ...s, entries: e.target.value }))}
/>
<p className="mt-1 text-xs text-muted">
Pro Zeile: Emoji | Role ID | Label (optional) | Beschreibung (optional)
</p>
</TextField>
<div className="mt-2 flex flex-col gap-2">
{reactionDraft.entries.map((entry, i) => {
const role = roles.find((r) => r.id === entry.roleId);
return (
<div key={i} className="bg-default-soft flex items-center gap-2 rounded-xl py-1.5 pl-3 pr-1.5 text-sm">
<span>{entry.emoji}</span>
{role?.color && <span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: role.color }} />}
<span className="min-w-0 flex-1 truncate">{entry.label || role?.name || entry.roleId}</span>
<button
type="button"
className="flex size-5 shrink-0 items-center justify-center rounded-full text-muted hover:bg-danger-soft hover:text-danger"
onClick={() => removeEntry(i)}
>
<X size={12} />
</button>
</div>
);
})}
{!reactionDraft.entries.length && <p className="text-xs text-muted">Noch keine Einträge hinzugefügt.</p>}
</div>
<Button variant="primary" onPress={saveReactionRole}>
<div className="mt-3 flex flex-col gap-2 rounded-xl border border-border p-3">
<div className="flex gap-2">
<Input
className="w-16 shrink-0"
placeholder="😀"
value={entryDraft.emoji}
onChange={(e) => setEntryDraft((s) => ({ ...s, emoji: e.target.value }))}
/>
<RoleSelect
options={roles}
value={entryDraft.roleId}
onChange={(id) => setEntryDraft((s) => ({ ...s, roleId: id }))}
placeholder="Rolle wählen"
/>
</div>
<Input
placeholder="Label (optional, z.B. Gamer)"
value={entryDraft.label}
onChange={(e) => setEntryDraft((s) => ({ ...s, label: e.target.value }))}
/>
<Button size="sm" variant="tertiary" onPress={addEntry} isDisabled={!entryDraft.emoji.trim() || !entryDraft.roleId}>
<Plus size={14} /> Eintrag hinzufügen
</Button>
</div>
</div>
<Button variant="primary" onPress={saveReactionRole} isDisabled={!reactionDraft.entries.length || !reactionDraft.channelId}>
<Save size={16} /> Reaction Role speichern
</Button>
</CardContent>

View File

@@ -2,7 +2,7 @@ import { Card, CardContent, CardHeader, Input, Button, Chip, Separator, TextFiel
import { Activity, Save, Trash2, Plus, BarChart3 } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { AppSwitch } from '../components/shared/AppSwitch';
import { ModuleActiveToggle } from '../components/shared/ModuleActiveToggle';
export function ServerStats() {
const { statsDraft, setStatsDraft, saveServerStats, statsItemDraft, setStatsItemDraft, addStatsItem, deleteStatsItem } = useApp();
@@ -17,10 +17,12 @@ export function ServerStats() {
<h3 className="text-base font-semibold">Konfiguration</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<AppSwitch
<ModuleActiveToggle
icon={<BarChart3 size={16} />}
title="Server Stats aktiv"
description="Zeigt Mitglieder-/Channel-Zahlen als Voice-Statistiken an."
isSelected={statsDraft?.enabled === true}
onChange={(v) => setStatsDraft((s) => ({ ...(s || {}), enabled: v }))}
label={<div className="flex items-center gap-2"><BarChart3 size={16} /> Server Stats aktiv</div>}
/>
<TextField>

View File

@@ -1,10 +1,11 @@
import { Card, CardContent, CardHeader, Button, Separator, TextField, Label } from '@heroui/react';
import { Settings, Save, Logs, Bell, Shield, Edit3, Trash2 } from 'lucide-react';
import { Settings, Save, Logs, Bell, Shield, Edit3, Trash2, ImageIcon, QrCode } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { RoleSelect } from '../components/shared/RoleSelect';
import { AppSwitch } from '../components/shared/AppSwitch';
import { ModuleActiveToggle } from '../components/shared/ModuleActiveToggle';
import { useGuildResources } from '../hooks/useGuildResources';
export function SettingsPage() {
@@ -99,6 +100,34 @@ export function SettingsPage() {
</Button>
</CardContent>
</Card>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Bild-Moderation</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<ModuleActiveToggle
icon={<ImageIcon size={16} />}
title="Bild-Moderation aktiv"
description="Scannt Bild-Anhänge auf QR-Codes und gleicht sie mit bekannten Scam-Mustern ab."
isSelected={settings.imageModerationConfig?.enabled === true}
onChange={(v) => setSettings((s) => ({ ...s, imageModerationConfig: { ...(s.imageModerationConfig || {}), enabled: v } }))}
/>
<AppSwitch
isSelected={settings.imageModerationConfig?.alertOnly === true}
onChange={(v) => setSettings((s) => ({ ...s, imageModerationConfig: { ...(s.imageModerationConfig || {}), alertOnly: v } }))}
label={<div className="flex items-center gap-2"><QrCode size={14} /> Nur bei erkanntem Scam-Verdacht alarmieren</div>}
description="Wenn aus, wird bei jedem gefundenen QR-Code alarmiert, nicht nur bei verdächtigen."
/>
<Separator />
<Button variant="primary" onPress={() => saveSettingsPayload(settings, 'Settings gespeichert')}>
<Save size={16} /> Speichern
</Button>
</CardContent>
</Card>
</div>
</SectionCard>
);

View File

@@ -4,7 +4,7 @@ import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import type { StatusService } from '../types';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { AppSwitch } from '../components/shared/AppSwitch';
import { ModuleActiveToggle } from '../components/shared/ModuleActiveToggle';
import { useGuildResources } from '../hooks/useGuildResources';
export function Statuspage() {
@@ -21,10 +21,12 @@ export function Statuspage() {
<h3 className="text-base font-semibold">Konfiguration</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<AppSwitch
<ModuleActiveToggle
icon={<RadioTower size={16} />}
title="Statuspage aktiv"
description="Postet und aktualisiert die Statusseite mit deinen Services."
isSelected={statusDraft?.enabled !== false}
onChange={(v) => setStatusDraft((s) => ({ ...(s || {}), enabled: v }))}
label={<div className="flex items-center gap-2"><RadioTower size={16} /> Statuspage aktiv</div>}
/>
<TextField>

View File

@@ -1,22 +1,31 @@
import { useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import { Card, CardContent, CardHeader, Chip, Button, Tabs, Tab, Input, TextArea, Separator, TextField, Label } from '@heroui/react';
import { Ticket, Clock, UserRound, CheckCircle, MessageSquare, FileText, Pencil, Trash2, ChevronRight } from 'lucide-react';
import { Ticket, Clock, UserRound, CheckCircle, MessageSquare, FileText, Pencil, Trash2, ChevronRight, Tag, Users } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { formatDate } from '../utils/formatters';
import { SectionCard } from '../components/shared/SectionCard';
import { StatCard } from '../components/shared/StatCard';
import { EmptyState } from '../components/shared/EmptyState';
import { TicketKanban } from '../components/shared/TicketKanban';
import { RoleSelect } from '../components/shared/RoleSelect';
import { useGuildResources } from '../hooks/useGuildResources';
export function Tickets() {
const {
tickets, pipeline, sla, automations, kbArticles, ticketTab, setTicketTab,
currentGuildId, tickets, pipeline, sla, automations, kbArticles, ticketTab, setTicketTab,
ticketDetail, setTicketDetail, ticketMessages, loadTicketMessages,
updateTicketStatus, closeTicket, automationDraft, setAutomationDraft,
saveAutomation, kbDraft, setKbDraft, saveKbArticle, kbEditDraft, setKbEditDraft,
updateKbArticle, deleteKbArticle, automationEditDraft, setAutomationEditDraft,
updateAutomation, deleteAutomation, overview
updateAutomation, deleteAutomation, overview,
ticketTopics, loadTicketTopics, ticketTopicDraft, setTicketTopicDraft, saveTicketTopic
} = useApp();
const { roles } = useGuildResources(currentGuildId);
useEffect(() => {
if (currentGuildId) loadTicketTopics();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentGuildId]);
const openTickets = useMemo(() => tickets.filter((t) => t.status !== 'closed'), [tickets]);
@@ -30,6 +39,7 @@ export function Tickets() {
<Tab id="sla">SLA</Tab>
<Tab id="automations">Automationen</Tab>
<Tab id="kb">Knowledge Base</Tab>
<Tab id="kategorien">Kategorien</Tab>
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
@@ -350,6 +360,85 @@ export function Tickets() {
</div>
</div>
)}
{ticketTab === 'kategorien' && (
<div className="mt-5 grid gap-5 xl:grid-cols-[1fr_420px]">
<div>
<h3 className="mb-3 text-base font-semibold">Konfigurierte Kategorien ({Object.keys(ticketTopics).length})</h3>
<p className="mb-3 text-xs text-muted">
Der Kategorie-Slug entspricht dem Ticket-Grund (z.B. <code>ban</code>, <code>help</code>, <code>feedback</code>, <code>other</code>). Ohne Konfiguration bleibt das bisherige Verhalten unverändert.
</p>
<div className="space-y-3">
{Object.keys(ticketTopics).length ? Object.entries(ticketTopics).map(([topic, cfg]) => (
<Card key={topic}>
<CardContent className="flex items-center justify-between gap-3 p-4">
<div className="flex min-w-0 items-center gap-3">
<div className="bg-accent-soft text-accent-soft-foreground flex size-9 shrink-0 items-center justify-center rounded-lg">
<Tag size={14} />
</div>
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{topic}</div>
<div className="flex items-center gap-1 text-xs text-muted">
<Users size={12} />
{cfg.roleId ? roles.find((r) => r.id === cfg.roleId)?.name || cfg.roleId : 'Standard-Support-Rolle'}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Chip size="sm" variant="soft">{cfg.questions?.length ?? 0} Fragen</Chip>
<Button
size="sm"
variant="tertiary"
onPress={() => setTicketTopicDraft({ topic, roleId: cfg.roleId || '', questions: (cfg.questions || []).join('\n') })}
>
<Pencil size={14} /> Bearbeiten
</Button>
</div>
</CardContent>
</Card>
)) : <EmptyState message="Noch keine Kategorien konfiguriert" icon={<Tag size={24} />} />}
</div>
</div>
<div>
<Card>
<CardHeader className="px-5 pt-5 pb-0">
<h3 className="text-base font-semibold">Kategorie {ticketTopicDraft.topic ? 'bearbeiten' : 'anlegen'}</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-5">
<TextField>
<Label>Kategorie-Slug</Label>
<Input
placeholder="z.B. help, ban, feedback, other"
value={ticketTopicDraft.topic}
onChange={(e) => setTicketTopicDraft((s: any) => ({ ...s, topic: e.target.value }))}
/>
</TextField>
<TextField>
<Label>Rolle, die gepingt wird</Label>
<RoleSelect options={roles} value={ticketTopicDraft.roleId} onChange={(id) => setTicketTopicDraft((s: any) => ({ ...s, roleId: id }))} placeholder="Standard-Support-Rolle" />
</TextField>
<TextField>
<Label>Fragen-Vorlage (eine pro Zeile, max. 5)</Label>
<TextArea
rows={5}
placeholder={'Was ist passiert?\nWann ist es aufgetreten?\nHast du einen Screenshot?'}
value={ticketTopicDraft.questions}
onChange={(e) => setTicketTopicDraft((s: any) => ({ ...s, questions: e.target.value }))}
/>
</TextField>
<p className="text-xs text-muted">Wenn Fragen hinterlegt sind, öffnet sich beim Ticket-Erstellen ein Formular statt sofort ein Kanal zu erstellen.</p>
<div className="flex gap-2">
<Button variant="primary" onPress={saveTicketTopic} isDisabled={!ticketTopicDraft.topic.trim()}>Speichern</Button>
{ticketTopicDraft.topic && (
<Button variant="tertiary" onPress={() => setTicketTopicDraft({ topic: '', roleId: '', questions: '' })}>Abbrechen</Button>
)}
</div>
</CardContent>
</Card>
</div>
</div>
)}
</SectionCard>
);
}

View File

@@ -1,16 +1,34 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Input, TextArea, Button, Separator, TextField, Label } from '@heroui/react';
import { Sparkles, Save } from 'lucide-react';
import { Sparkles, Save, X } from 'lucide-react';
import { useApp } from '../context/AppContext';
import { SectionCard } from '../components/shared/SectionCard';
import { DiscordPreview } from '../components/shared/DiscordPreview';
import { ChannelSelect } from '../components/shared/ChannelSelect';
import { AppSwitch } from '../components/shared/AppSwitch';
import { ModuleActiveToggle } from '../components/shared/ModuleActiveToggle';
import { useGuildResources } from '../hooks/useGuildResources';
const MAX_IMAGE_BYTES = 3 * 1024 * 1024;
export function Welcome() {
const { settings, setSettings, saveSettingsPayload, currentGuildId } = useApp();
const { channels } = useGuildResources(currentGuildId);
const imagePreview = settings.welcomeConfig?.embedImageData || settings.welcomeConfig?.embedImage;
const handleImageUpload = (file: File) => {
if (file.size > MAX_IMAGE_BYTES) {
alert('Bild ist zu groß (max. 3 MB).');
return;
}
const reader = new FileReader();
reader.onload = () => {
setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), embedImageData: reader.result as string, embedImage: undefined } }));
};
reader.readAsDataURL(file);
};
const clearImage = () => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), embedImage: undefined, embedImageData: undefined } }));
return (
<SectionCard title="Willkommen" subtitle="Welcome-Embeds und Join-Nachrichten">
<div className="grid gap-5 xl:grid-cols-2">
@@ -22,10 +40,12 @@ export function Welcome() {
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<AppSwitch
<ModuleActiveToggle
icon={<Sparkles size={16} />}
title="Welcome aktiv"
description="Sendet ein Begrüßungs-Embed, wenn neue Mitglieder beitreten."
isSelected={settings.welcomeConfig?.enabled !== false}
onChange={(v) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), enabled: v } }))}
label={<div className="flex items-center gap-2"><Sparkles size={16} /> Welcome aktiv</div>}
/>
<TextField>
@@ -65,6 +85,38 @@ export function Welcome() {
/>
</TextField>
<TextField>
<Label>Bild-URL</Label>
<Input
placeholder="https://..."
value={settings.welcomeConfig?.embedImage || ''}
onChange={(e) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), embedImage: e.target.value, embedImageData: undefined } }))}
/>
</TextField>
<TextField>
<Label>oder Bild hochladen</Label>
<input
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
className="text-sm text-muted"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleImageUpload(file);
e.target.value = '';
}}
/>
</TextField>
{imagePreview && (
<div className="flex items-center gap-2">
<img src={imagePreview} alt="Vorschau" className="h-12 w-20 rounded object-cover" />
<Button size="sm" variant="danger-soft" onPress={clearImage}>
<X size={14} /> Bild entfernen
</Button>
</div>
)}
<Separator />
<Button size="lg" variant="primary" onPress={() => saveSettingsPayload({ welcomeConfig: settings.welcomeConfig || {} }, 'Welcome gespeichert')}>
@@ -85,6 +137,7 @@ export function Welcome() {
title={settings.welcomeConfig?.embedTitle || 'Willkommen!'}
description={settings.welcomeConfig?.embedDescription || 'Willkommen auf dem Server!'}
footer={settings.welcomeConfig?.embedFooter}
image={imagePreview}
/>
<p className="text-sm text-muted">

View File

@@ -38,6 +38,8 @@ export type NavKey =
| 'watchlist'
| 'branding'
| 'growth'
| 'permissions'
| 'panels'
| 'admin';
export type TicketRecord = {
@@ -179,6 +181,55 @@ export type GrowthStats = {
dailyJoins: { day: string; count: number }[];
};
export type InviteBreakdownEntry = {
code: string;
inviterId?: string;
uses: number;
joins30: number;
suspiciousJoins30: number;
};
export type RecentJoinEntry = {
id: string;
userId: string;
inviterId?: string | null;
inviteCode?: string | null;
suspicious: boolean;
createdAt: string;
};
export type PermissionScanResult = {
adminRoles: { id: string; name: string }[];
banRoles: { id: string; name: string }[];
dangerousBots: { id: string; tag: string; perms: string[] }[];
publicChannels: { id: string; name: string }[];
everyoneCanSendChannels: { id: string; name: string }[];
emptyRoles: { id: string; name: string }[];
duplicateRoleGroups: { id: string; name: string }[][];
uselessRoles: { id: string; name: string }[];
tooManyAdmins: boolean;
adminMemberCount: number;
};
export type InfoPanelType = 'rules' | 'support' | 'bewerbung' | 'partner' | 'rollen' | 'events' | 'faq';
export type InfoPanel = {
id: string;
guildId: string;
channelId: string;
messageId?: string | null;
type: InfoPanelType;
title: string;
description?: string | null;
items?: { question: string; answer: string }[] | null;
createdAt?: string;
};
export type TicketTopicConfig = {
roleId?: string;
questions?: string[];
};
export type MusicSession = {
guildId: string;
nowPlaying?: { title: string; url: string } | null;

17
node_modules/.bin/acorn generated vendored
View File

@@ -1,16 +1 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../acorn/bin/acorn" "$@"
else
exec node "$basedir/../acorn/bin/acorn" "$@"
fi
../acorn/bin/acorn

17
node_modules/.bin/mime generated vendored
View File

@@ -1,16 +1 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../mime/cli.js" "$@"
else
exec node "$basedir/../mime/cli.js" "$@"
fi
../mime/cli.js

17
node_modules/.bin/mkdirp generated vendored
View File

@@ -1,16 +1 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../mkdirp/bin/cmd.js" "$@"
else
exec node "$basedir/../mkdirp/bin/cmd.js" "$@"
fi
../mkdirp/bin/cmd.js

17
node_modules/.bin/prisma generated vendored
View File

@@ -1,16 +1 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../prisma/build/index.js" "$@"
else
exec node "$basedir/../prisma/build/index.js" "$@"
fi
../prisma/build/index.js

17
node_modules/.bin/resolve generated vendored
View File

@@ -1,16 +1 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../resolve/bin/resolve" "$@"
else
exec node "$basedir/../resolve/bin/resolve" "$@"
fi
../resolve/bin/resolve

17
node_modules/.bin/rimraf generated vendored
View File

@@ -1,16 +1 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../rimraf/bin.js" "$@"
else
exec node "$basedir/../rimraf/bin.js" "$@"
fi
../rimraf/bin.js

17
node_modules/.bin/tree-kill generated vendored
View File

@@ -1,16 +1 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../tree-kill/cli.js" "$@"
else
exec node "$basedir/../tree-kill/cli.js" "$@"
fi
../tree-kill/cli.js

17
node_modules/.bin/ts-node generated vendored
View File

@@ -1,16 +1 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../ts-node/dist/bin.js" "$@"
else
exec node "$basedir/../ts-node/dist/bin.js" "$@"
fi
../ts-node/dist/bin.js

17
node_modules/.bin/ts-node-cwd generated vendored
View File

@@ -1,16 +1 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../ts-node/dist/bin-cwd.js" "$@"
else
exec node "$basedir/../ts-node/dist/bin-cwd.js" "$@"
fi
../ts-node/dist/bin-cwd.js

17
node_modules/.bin/ts-node-dev generated vendored
View File

@@ -1,16 +1 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../ts-node-dev/lib/bin.js" "$@"
else
exec node "$basedir/../ts-node-dev/lib/bin.js" "$@"
fi
../ts-node-dev/lib/bin.js

17
node_modules/.bin/ts-node-esm generated vendored
View File

@@ -1,16 +1 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../ts-node/dist/bin-esm.js" "$@"
else
exec node "$basedir/../ts-node/dist/bin-esm.js" "$@"
fi
../ts-node/dist/bin-esm.js

17
node_modules/.bin/ts-node-script generated vendored
View File

@@ -1,16 +1 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../ts-node/dist/bin-script.js" "$@"
else
exec node "$basedir/../ts-node/dist/bin-script.js" "$@"
fi
../ts-node/dist/bin-script.js

View File

@@ -1,16 +1 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../ts-node/dist/bin-transpile.js" "$@"
else
exec node "$basedir/../ts-node/dist/bin-transpile.js" "$@"
fi
../ts-node/dist/bin-transpile.js

17
node_modules/.bin/ts-script generated vendored
View File

@@ -1,16 +1 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../ts-node/dist/bin-script-deprecated.js" "$@"
else
exec node "$basedir/../ts-node/dist/bin-script-deprecated.js" "$@"
fi
../ts-node/dist/bin-script-deprecated.js

17
node_modules/.bin/tsc generated vendored
View File

@@ -1,16 +1 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
else
exec node "$basedir/../typescript/bin/tsc" "$@"
fi
../typescript/bin/tsc

17
node_modules/.bin/tsnd generated vendored
View File

@@ -1,16 +1 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../ts-node-dev/lib/bin.js" "$@"
else
exec node "$basedir/../ts-node-dev/lib/bin.js" "$@"
fi
../ts-node-dev/lib/bin.js

17
node_modules/.bin/tsserver generated vendored
View File

@@ -1,16 +1 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
else
exec node "$basedir/../typescript/bin/tsserver" "$@"
fi
../typescript/bin/tsserver

823
node_modules/.package-lock.json generated vendored
View File

@@ -4,6 +4,16 @@
"lockfileVersion": 3,
"requires": true,
"packages": {
"node_modules/@borewit/text-codec": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
"integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -264,6 +274,430 @@
"scripts/actions/documentation"
]
},
"node_modules/@jimp/core": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/core/-/core-1.6.1.tgz",
"integrity": "sha512-+BoKC5G6hkrSy501zcJ2EpfnllP+avPevcBfRcZe/CW+EwEfY6X1EZ8QWyT7NpDIvEEJb1fdJnMMfUnFkxmw9A==",
"license": "MIT",
"dependencies": {
"@jimp/file-ops": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"await-to-js": "^3.0.0",
"exif-parser": "^0.1.12",
"file-type": "^21.3.3",
"mime": "3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/core/node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/@jimp/diff": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/diff/-/diff-1.6.1.tgz",
"integrity": "sha512-YkKDPdHjLgo1Api3+Bhc0GLAygldlpt97NfOKoNg1U6IUNXA6X2MgosCjPfSBiSvJvrrz1fsIR+/4cfYXBI/HQ==",
"license": "MIT",
"dependencies": {
"@jimp/plugin-resize": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"pixelmatch": "^5.3.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/file-ops": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/file-ops/-/file-ops-1.6.1.tgz",
"integrity": "sha512-T+gX6osHjprbDRad0/B71Evyre7ZdVY1z/gFGEG9Z8KOtZPKboWvPeP2UjbZYWQLy9UKCPQX1FNAnDiOPkJL7w==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/js-bmp": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/js-bmp/-/js-bmp-1.6.1.tgz",
"integrity": "sha512-xzWzNT4/u5zGrTT3Tme9sGU7YzIKxi13+BCQwLqACbt5DXf9SAfdzRkopZQnmDko+6In5nqaT89Gjs43/WdnYQ==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"bmp-ts": "^1.0.9"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/js-gif": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/js-gif/-/js-gif-1.6.1.tgz",
"integrity": "sha512-YjY2W26rQa05XhanYhRZ7dingCiNN+T2Ymb1JiigIbABY0B28wHE3v3Cf1/HZPWGu0hOg36ylaKgV5KxF2M58w==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"gifwrap": "^0.10.1",
"omggif": "^1.0.10"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/js-jpeg": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/js-jpeg/-/js-jpeg-1.6.1.tgz",
"integrity": "sha512-HT9H3yOmlOFzYmdI15IYdfy6ggQhSRIaHeA+OTJSEORXBqEo97sUZu/DsgHIcX5NJ7TkJBTgZ9BZXsV6UbsyMg==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"jpeg-js": "^0.4.4"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/js-png": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/js-png/-/js-png-1.6.1.tgz",
"integrity": "sha512-SZ/KVhI5UjcSzzlXsXdIi/LhJ7UShf2NkMOtVrbZQcGzsqNtynAelrOXeoTxcanfVqmNhAoVHg8yR2cYoqrYjA==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"pngjs": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/js-tiff": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/js-tiff/-/js-tiff-1.6.1.tgz",
"integrity": "sha512-jDG/eJquID1M4MBlKMmDRBmz2TpXMv7TUyu2nIRUxhlUc2ogC82T+VQUkca9GJH1BBJ9dx5sSE5dGkWNjIbZxw==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"utif2": "^4.1.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-blit": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-1.6.1.tgz",
"integrity": "sha512-MwnI7C7K81uWddY9FLw1fCOIy6SsPIUftUz36Spt7jisCn8/40DhQMlSxpxTNelnZb/2SnloFimQfRZAmHLOqQ==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-blur": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-1.6.1.tgz",
"integrity": "sha512-lIo7Tzp5jQu30EFFSK/phXANK3citKVEjepDjQ6ljHoIFtuMRrnybnmI2Md24ulvWlDaz+hh3n6qrMb8ydwhZQ==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/utils": "1.6.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-circle": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-1.6.1.tgz",
"integrity": "sha512-kK1PavY6cKHNNKce37vdV4Tmpc1/zDKngGoeOV3j+EMatoHFZUinV3s6F9aWryPs3A0xhCLZgdJ6Zeea1d5LCQ==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-color": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-1.6.1.tgz",
"integrity": "sha512-LtUN1vAP+LRlZAtTNVhDRSiXx+26Kbz3zJaG6a5k59gQ95jgT5mknnF8lxkHcqJthM4MEk3/tPxkdJpEybyF/A==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"tinycolor2": "^1.6.0",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-contain": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-1.6.1.tgz",
"integrity": "sha512-m0qhrfA8jkTqretGv4w+T/ADFR4GwBpE0sCOC2uJ0dzr44/ddOMsIdrpi89kabqYiPYIrxkgdCVCLm3zn1Vkkg==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/plugin-blit": "1.6.1",
"@jimp/plugin-resize": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-cover": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-1.6.1.tgz",
"integrity": "sha512-hZytnsth0zoll6cPf434BrT+p/v569Wr5tyO6Dp0dH1IDPhzhB5F38sZGMLDo7bzQiN9JFVB3fxkcJ/WYCJ3Mg==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/plugin-crop": "1.6.1",
"@jimp/plugin-resize": "1.6.1",
"@jimp/types": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-crop": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-1.6.1.tgz",
"integrity": "sha512-EerRSLlclXyKDnYc/H9w/1amZW7b7v3OGi/VlerPd2M/pAu5X8TkyYWtfqYCXnNp1Ixtd8oCo9zGfY9zoXT4rg==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-displace": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-1.6.1.tgz",
"integrity": "sha512-K07QVl7xQwIfD6KfxRV/c3E9e7ZBXxUXdWuvoTWcKHL2qV48MOF5Nqbz/aJW4ThnQARIsxvYlZjPFiqkCjlU+g==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-dither": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-1.6.1.tgz",
"integrity": "sha512-+2V+GCV2WycMoX1/z977TkZ8Zq/4MVSKElHYatgUqtwXMi2fDK2gKYU2g9V39IqFvTJsTIsK0+58VFz/ROBVew==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-fisheye": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-1.6.1.tgz",
"integrity": "sha512-XtS5ZyoZ0vxZxJ6gkqI63SivhtI58vX95foMPM+cyzYkRsJXMOYCr8DScxF5bp4Xr003NjYm/P+7+08tibwzHA==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-flip": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-1.6.1.tgz",
"integrity": "sha512-ws38W/sGj7LobNRayQ83garxiktOyWxM5vO/y4a/2cy9v65SLEUzVkrj+oeAaUSSObdz4HcCEla7XtGlnAGAaA==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-hash": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-hash/-/plugin-hash-1.6.1.tgz",
"integrity": "sha512-sZt6ZcMX6i8vFWb4GYnw0pR/o9++ef0dTVcboTB5B/g7nrxCODIB4wfEkJ/YqZM5wUvol77K1qeS0/rVO6z21A==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/js-bmp": "1.6.1",
"@jimp/js-jpeg": "1.6.1",
"@jimp/js-png": "1.6.1",
"@jimp/js-tiff": "1.6.1",
"@jimp/plugin-color": "1.6.1",
"@jimp/plugin-resize": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"any-base": "^1.1.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-mask": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-1.6.1.tgz",
"integrity": "sha512-SIG0/FcmEj3tkwFxc7fAGLO8o4uNzMpSOdQOhbCgxefQKq5wOVMk9BQx/sdMPBwtMLr9WLq0GzLA/rk6t2v20A==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-print": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-1.6.1.tgz",
"integrity": "sha512-BYVz/X3Xzv8XYilVeDy11NOp0h7BTDjlOtu0BekIFHP1yHVd24AXNzbOy52XlzYZWQ0Dl36HOHEpl/nSNrzc6w==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/js-jpeg": "1.6.1",
"@jimp/js-png": "1.6.1",
"@jimp/plugin-blit": "1.6.1",
"@jimp/types": "1.6.1",
"parse-bmfont-ascii": "^1.0.6",
"parse-bmfont-binary": "^1.0.6",
"parse-bmfont-xml": "^1.1.6",
"simple-xml-to-json": "^1.2.2",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-quantize": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-quantize/-/plugin-quantize-1.6.1.tgz",
"integrity": "sha512-J2En9PLURfP+vwYDtuZ9T8yBW6BWYZBScydAjRiPBmJfEhTcNQqiiQODrZf7EqbbX/Sy5H6dAeRiqkgoV9N6Ww==",
"license": "MIT",
"dependencies": {
"image-q": "^4.0.0",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-resize": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-1.6.1.tgz",
"integrity": "sha512-CLkrtJoIz2HdWnpYiN6p8KYcPc00rCH/SUu6o+lfZL05Q4uhecJlnvXuj9x+U6mDn3ldPmJj6aZqMHuUJzdVqg==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-rotate": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-1.6.1.tgz",
"integrity": "sha512-nOjVjbbj705B02ksysKnh0POAwEBXZtJ9zQ5qC+X7Tavl3JNn+P3BzQovbBxLPSbUSld6XID9z5ijin4PtOAUg==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/plugin-crop": "1.6.1",
"@jimp/plugin-resize": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-threshold": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-1.6.1.tgz",
"integrity": "sha512-JOKv9F8s6tnVLf4sB/2fF0F339EFnHvgEdFYugO6VhowKLsap0pEZmLyE/DlRnYtIj2RddHZVxVMp/eKJ04l2Q==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/plugin-color": "1.6.1",
"@jimp/plugin-hash": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/types": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/types/-/types-1.6.1.tgz",
"integrity": "sha512-leI7YbveTNi565m910XgIOwXyuu074H5qazAD1357HImJSv2hqxnWXpwxQbadGWZ7goZRYBDZy5lpqud0p7q5w==",
"license": "MIT",
"dependencies": {
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/utils": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-1.6.1.tgz",
"integrity": "sha512-veFPRd93FCnS7AgmCkPgARVGoDRrJ9cm1ujuNyA+UfQ5VKbED2002sm5XfFLFwTsKC8j04heTrwe+tU1dluXOw==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"tinycolor2": "^1.6.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -421,22 +855,84 @@
"@snazzah/davey-win32-x64-msvc": "0.1.8"
}
},
"node_modules/@snazzah/davey-win32-x64-msvc": {
"node_modules/@snazzah/davey-linux-x64-gnu": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/@snazzah/davey-win32-x64-msvc/-/davey-win32-x64-msvc-0.1.8.tgz",
"integrity": "sha512-JKIco1miwtM4NgVwU/H9TJdUaSlJ+kdtydy3+tiV9cmJv0u1SM2NpwjV85H44xAQWW2zcRBHP0ZDriciHw09qQ==",
"resolved": "https://registry.npmjs.org/@snazzah/davey-linux-x64-gnu/-/davey-linux-x64-gnu-0.1.8.tgz",
"integrity": "sha512-yghgG7iXZUHy734Cq3PcgrbRnLhhB233JNTX5VPRxRqdwFAg2MzAJ2iSWpP12K6hSqKq9hw0sdt8CNpr0mEXjQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@snazzah/davey-linux-x64-musl": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/@snazzah/davey-linux-x64-musl/-/davey-linux-x64-musl-0.1.8.tgz",
"integrity": "sha512-WwCiAge27ZOEu7NRx5NFjpCAkGcUGHGtoROBY4ElRYE0Tp3DfuzWU06qSu6JBQPlzhTTBN29X2/kGP8iRUwqnQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tokenizer/inflate": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
"integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"token-types": "^6.1.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@tokenizer/inflate/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@tokenizer/inflate/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"license": "MIT"
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
@@ -724,6 +1220,12 @@
"node": ">=8"
}
},
"node_modules/any-base": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz",
"integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==",
"license": "MIT"
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@@ -771,6 +1273,15 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/await-to-js": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/await-to-js/-/await-to-js-3.0.0.tgz",
"integrity": "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -831,6 +1342,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bmp-ts": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/bmp-ts/-/bmp-ts-1.0.9.tgz",
"integrity": "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@@ -1279,6 +1796,11 @@
"node": ">= 0.6"
}
},
"node_modules/exif-parser": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz",
"integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="
},
"node_modules/express": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
@@ -1381,6 +1903,24 @@
"node": ">=16"
}
},
"node_modules/file-type": {
"version": "21.3.4",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz",
"integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==",
"license": "MIT",
"dependencies": {
"@tokenizer/inflate": "^0.4.1",
"strtok3": "^10.3.4",
"token-types": "^6.1.1",
"uint8array-extras": "^1.4.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -1527,6 +2067,16 @@
"node": ">= 0.4"
}
},
"node_modules/gifwrap": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.10.1.tgz",
"integrity": "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==",
"license": "MIT",
"dependencies": {
"image-q": "^4.0.0",
"omggif": "^1.0.10"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -1682,6 +2232,41 @@
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/image-q": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/image-q/-/image-q-4.0.0.tgz",
"integrity": "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==",
"license": "MIT",
"dependencies": {
"@types/node": "16.9.1"
}
},
"node_modules/image-q/node_modules/@types/node": {
"version": "16.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz",
"integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==",
"license": "MIT"
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -1779,6 +2364,56 @@
"node": ">=0.12.0"
}
},
"node_modules/jimp": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/jimp/-/jimp-1.6.1.tgz",
"integrity": "sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/diff": "1.6.1",
"@jimp/js-bmp": "1.6.1",
"@jimp/js-gif": "1.6.1",
"@jimp/js-jpeg": "1.6.1",
"@jimp/js-png": "1.6.1",
"@jimp/js-tiff": "1.6.1",
"@jimp/plugin-blit": "1.6.1",
"@jimp/plugin-blur": "1.6.1",
"@jimp/plugin-circle": "1.6.1",
"@jimp/plugin-color": "1.6.1",
"@jimp/plugin-contain": "1.6.1",
"@jimp/plugin-cover": "1.6.1",
"@jimp/plugin-crop": "1.6.1",
"@jimp/plugin-displace": "1.6.1",
"@jimp/plugin-dither": "1.6.1",
"@jimp/plugin-fisheye": "1.6.1",
"@jimp/plugin-flip": "1.6.1",
"@jimp/plugin-hash": "1.6.1",
"@jimp/plugin-mask": "1.6.1",
"@jimp/plugin-print": "1.6.1",
"@jimp/plugin-quantize": "1.6.1",
"@jimp/plugin-resize": "1.6.1",
"@jimp/plugin-rotate": "1.6.1",
"@jimp/plugin-threshold": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/jpeg-js": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
"license": "BSD-3-Clause"
},
"node_modules/jsqr": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
"license": "Apache-2.0"
},
"node_modules/libsodium": {
"version": "0.7.15",
"resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.7.15.tgz",
@@ -2080,6 +2715,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/omggif": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz",
"integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==",
"license": "MIT"
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -2110,6 +2751,34 @@
"wrappy": "1"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse-bmfont-ascii": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz",
"integrity": "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA==",
"license": "MIT"
},
"node_modules/parse-bmfont-binary": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz",
"integrity": "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA==",
"license": "MIT"
},
"node_modules/parse-bmfont-xml": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/parse-bmfont-xml/-/parse-bmfont-xml-1.1.6.tgz",
"integrity": "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA==",
"license": "MIT",
"dependencies": {
"xml-parse-from-string": "^1.0.0",
"xml2js": "^0.5.0"
}
},
"node_modules/parse-cache-control": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz",
@@ -2159,6 +2828,27 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pixelmatch": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.3.0.tgz",
"integrity": "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==",
"license": "ISC",
"dependencies": {
"pngjs": "^6.0.0"
},
"bin": {
"pixelmatch": "bin/pixelmatch"
}
},
"node_modules/pixelmatch/node_modules/pngjs": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
"license": "MIT",
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/play-audio": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/play-audio/-/play-audio-0.5.2.tgz",
@@ -2177,6 +2867,15 @@
"node": ">=16.0.0"
}
},
"node_modules/pngjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"license": "MIT",
"engines": {
"node": ">=14.19.0"
}
},
"node_modules/prism-media": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz",
@@ -2393,6 +3092,15 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sax": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@@ -2549,6 +3257,15 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/simple-xml-to-json": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/simple-xml-to-json/-/simple-xml-to-json-1.2.7.tgz",
"integrity": "sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q==",
"license": "MIT",
"engines": {
"node": ">=20.12.2"
}
},
"node_modules/sodium-native": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.3.tgz",
@@ -2643,6 +3360,22 @@
"node": ">=0.10.0"
}
},
"node_modules/strtok3": {
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
"integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==",
"license": "MIT",
"dependencies": {
"@tokenizer/token": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -2673,6 +3406,12 @@
"node": ">=10"
}
},
"node_modules/tinycolor2": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -2695,6 +3434,24 @@
"node": ">=0.6"
}
},
"node_modules/token-types": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
"integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
"license": "MIT",
"dependencies": {
"@borewit/text-codec": "^0.2.1",
"@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -2860,6 +3617,18 @@
"node": ">= 0.8"
}
},
"node_modules/uint8array-extras": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
"integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici": {
"version": "6.21.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
@@ -2884,6 +3653,15 @@
"node": ">= 0.8"
}
},
"node_modules/utif2": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz",
"integrity": "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.11"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -2967,6 +3745,34 @@
}
}
},
"node_modules/xml-parse-from-string": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz",
"integrity": "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==",
"license": "MIT"
},
"node_modules/xml2js": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@@ -2992,6 +3798,15 @@
"engines": {
"node": ">=6"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

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

File diff suppressed because one or more lines are too long

View File

@@ -151,6 +151,8 @@ exports.Prisma.GuildSettingsScalarFieldEnum = {
brandingConfig: 'brandingConfig',
partnerConfig: 'partnerConfig',
galleryConfig: 'galleryConfig',
imageModerationConfig: 'imageModerationConfig',
ticketConfig: 'ticketConfig',
supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt',
createdAt: 'createdAt'
@@ -469,6 +471,20 @@ exports.Prisma.GuildGrowthEventScalarFieldEnum = {
type: 'type',
userId: 'userId',
inviteCode: 'inviteCode',
inviterId: 'inviterId',
suspicious: 'suspicious',
createdAt: 'createdAt'
};
exports.Prisma.InfoPanelScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
channelId: 'channelId',
messageId: 'messageId',
type: 'type',
title: 'title',
description: 'description',
items: 'items',
createdAt: 'createdAt'
};
@@ -534,7 +550,8 @@ exports.Prisma.ModelName = {
PartnerRequest: 'PartnerRequest',
GalleryPost: 'GalleryPost',
GalleryVote: 'GalleryVote',
GuildGrowthEvent: 'GuildGrowthEvent'
GuildGrowthEvent: 'GuildGrowthEvent',
InfoPanel: 'InfoPanel'
};
/**

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because one or more lines are too long

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

@@ -151,6 +151,8 @@ exports.Prisma.GuildSettingsScalarFieldEnum = {
brandingConfig: 'brandingConfig',
partnerConfig: 'partnerConfig',
galleryConfig: 'galleryConfig',
imageModerationConfig: 'imageModerationConfig',
ticketConfig: 'ticketConfig',
supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt',
createdAt: 'createdAt'
@@ -469,6 +471,20 @@ exports.Prisma.GuildGrowthEventScalarFieldEnum = {
type: 'type',
userId: 'userId',
inviteCode: 'inviteCode',
inviterId: 'inviterId',
suspicious: 'suspicious',
createdAt: 'createdAt'
};
exports.Prisma.InfoPanelScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
channelId: 'channelId',
messageId: 'messageId',
type: 'type',
title: 'title',
description: 'description',
items: 'items',
createdAt: 'createdAt'
};
@@ -534,7 +550,8 @@ exports.Prisma.ModelName = {
PartnerRequest: 'PartnerRequest',
GalleryPost: 'GalleryPost',
GalleryVote: 'GalleryVote',
GuildGrowthEvent: 'GuildGrowthEvent'
GuildGrowthEvent: 'GuildGrowthEvent',
InfoPanel: 'InfoPanel'
};
/**

802
package-lock.json generated
View File

@@ -20,6 +20,8 @@
"express": "^4.18.2",
"express-session": "^1.17.3",
"ffmpeg-static": "^5.2.0",
"jimp": "^1.6.1",
"jsqr": "^1.4.0",
"libsodium-wrappers": "^0.7.13",
"play-dl": "^1.9.7",
"sodium-native": "^4.0.4"
@@ -34,6 +36,16 @@
"typescript": "^5.2.2"
}
},
"node_modules/@borewit/text-codec": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
"integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -325,6 +337,430 @@
"tslib": "^2.4.0"
}
},
"node_modules/@jimp/core": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/core/-/core-1.6.1.tgz",
"integrity": "sha512-+BoKC5G6hkrSy501zcJ2EpfnllP+avPevcBfRcZe/CW+EwEfY6X1EZ8QWyT7NpDIvEEJb1fdJnMMfUnFkxmw9A==",
"license": "MIT",
"dependencies": {
"@jimp/file-ops": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"await-to-js": "^3.0.0",
"exif-parser": "^0.1.12",
"file-type": "^21.3.3",
"mime": "3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/core/node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/@jimp/diff": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/diff/-/diff-1.6.1.tgz",
"integrity": "sha512-YkKDPdHjLgo1Api3+Bhc0GLAygldlpt97NfOKoNg1U6IUNXA6X2MgosCjPfSBiSvJvrrz1fsIR+/4cfYXBI/HQ==",
"license": "MIT",
"dependencies": {
"@jimp/plugin-resize": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"pixelmatch": "^5.3.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/file-ops": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/file-ops/-/file-ops-1.6.1.tgz",
"integrity": "sha512-T+gX6osHjprbDRad0/B71Evyre7ZdVY1z/gFGEG9Z8KOtZPKboWvPeP2UjbZYWQLy9UKCPQX1FNAnDiOPkJL7w==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/js-bmp": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/js-bmp/-/js-bmp-1.6.1.tgz",
"integrity": "sha512-xzWzNT4/u5zGrTT3Tme9sGU7YzIKxi13+BCQwLqACbt5DXf9SAfdzRkopZQnmDko+6In5nqaT89Gjs43/WdnYQ==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"bmp-ts": "^1.0.9"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/js-gif": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/js-gif/-/js-gif-1.6.1.tgz",
"integrity": "sha512-YjY2W26rQa05XhanYhRZ7dingCiNN+T2Ymb1JiigIbABY0B28wHE3v3Cf1/HZPWGu0hOg36ylaKgV5KxF2M58w==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"gifwrap": "^0.10.1",
"omggif": "^1.0.10"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/js-jpeg": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/js-jpeg/-/js-jpeg-1.6.1.tgz",
"integrity": "sha512-HT9H3yOmlOFzYmdI15IYdfy6ggQhSRIaHeA+OTJSEORXBqEo97sUZu/DsgHIcX5NJ7TkJBTgZ9BZXsV6UbsyMg==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"jpeg-js": "^0.4.4"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/js-png": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/js-png/-/js-png-1.6.1.tgz",
"integrity": "sha512-SZ/KVhI5UjcSzzlXsXdIi/LhJ7UShf2NkMOtVrbZQcGzsqNtynAelrOXeoTxcanfVqmNhAoVHg8yR2cYoqrYjA==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"pngjs": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/js-tiff": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/js-tiff/-/js-tiff-1.6.1.tgz",
"integrity": "sha512-jDG/eJquID1M4MBlKMmDRBmz2TpXMv7TUyu2nIRUxhlUc2ogC82T+VQUkca9GJH1BBJ9dx5sSE5dGkWNjIbZxw==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"utif2": "^4.1.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-blit": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-1.6.1.tgz",
"integrity": "sha512-MwnI7C7K81uWddY9FLw1fCOIy6SsPIUftUz36Spt7jisCn8/40DhQMlSxpxTNelnZb/2SnloFimQfRZAmHLOqQ==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-blur": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-1.6.1.tgz",
"integrity": "sha512-lIo7Tzp5jQu30EFFSK/phXANK3citKVEjepDjQ6ljHoIFtuMRrnybnmI2Md24ulvWlDaz+hh3n6qrMb8ydwhZQ==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/utils": "1.6.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-circle": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-1.6.1.tgz",
"integrity": "sha512-kK1PavY6cKHNNKce37vdV4Tmpc1/zDKngGoeOV3j+EMatoHFZUinV3s6F9aWryPs3A0xhCLZgdJ6Zeea1d5LCQ==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-color": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-1.6.1.tgz",
"integrity": "sha512-LtUN1vAP+LRlZAtTNVhDRSiXx+26Kbz3zJaG6a5k59gQ95jgT5mknnF8lxkHcqJthM4MEk3/tPxkdJpEybyF/A==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"tinycolor2": "^1.6.0",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-contain": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-1.6.1.tgz",
"integrity": "sha512-m0qhrfA8jkTqretGv4w+T/ADFR4GwBpE0sCOC2uJ0dzr44/ddOMsIdrpi89kabqYiPYIrxkgdCVCLm3zn1Vkkg==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/plugin-blit": "1.6.1",
"@jimp/plugin-resize": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-cover": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-1.6.1.tgz",
"integrity": "sha512-hZytnsth0zoll6cPf434BrT+p/v569Wr5tyO6Dp0dH1IDPhzhB5F38sZGMLDo7bzQiN9JFVB3fxkcJ/WYCJ3Mg==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/plugin-crop": "1.6.1",
"@jimp/plugin-resize": "1.6.1",
"@jimp/types": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-crop": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-1.6.1.tgz",
"integrity": "sha512-EerRSLlclXyKDnYc/H9w/1amZW7b7v3OGi/VlerPd2M/pAu5X8TkyYWtfqYCXnNp1Ixtd8oCo9zGfY9zoXT4rg==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-displace": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-1.6.1.tgz",
"integrity": "sha512-K07QVl7xQwIfD6KfxRV/c3E9e7ZBXxUXdWuvoTWcKHL2qV48MOF5Nqbz/aJW4ThnQARIsxvYlZjPFiqkCjlU+g==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-dither": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-1.6.1.tgz",
"integrity": "sha512-+2V+GCV2WycMoX1/z977TkZ8Zq/4MVSKElHYatgUqtwXMi2fDK2gKYU2g9V39IqFvTJsTIsK0+58VFz/ROBVew==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-fisheye": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-1.6.1.tgz",
"integrity": "sha512-XtS5ZyoZ0vxZxJ6gkqI63SivhtI58vX95foMPM+cyzYkRsJXMOYCr8DScxF5bp4Xr003NjYm/P+7+08tibwzHA==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-flip": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-1.6.1.tgz",
"integrity": "sha512-ws38W/sGj7LobNRayQ83garxiktOyWxM5vO/y4a/2cy9v65SLEUzVkrj+oeAaUSSObdz4HcCEla7XtGlnAGAaA==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-hash": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-hash/-/plugin-hash-1.6.1.tgz",
"integrity": "sha512-sZt6ZcMX6i8vFWb4GYnw0pR/o9++ef0dTVcboTB5B/g7nrxCODIB4wfEkJ/YqZM5wUvol77K1qeS0/rVO6z21A==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/js-bmp": "1.6.1",
"@jimp/js-jpeg": "1.6.1",
"@jimp/js-png": "1.6.1",
"@jimp/js-tiff": "1.6.1",
"@jimp/plugin-color": "1.6.1",
"@jimp/plugin-resize": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"any-base": "^1.1.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-mask": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-1.6.1.tgz",
"integrity": "sha512-SIG0/FcmEj3tkwFxc7fAGLO8o4uNzMpSOdQOhbCgxefQKq5wOVMk9BQx/sdMPBwtMLr9WLq0GzLA/rk6t2v20A==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-print": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-1.6.1.tgz",
"integrity": "sha512-BYVz/X3Xzv8XYilVeDy11NOp0h7BTDjlOtu0BekIFHP1yHVd24AXNzbOy52XlzYZWQ0Dl36HOHEpl/nSNrzc6w==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/js-jpeg": "1.6.1",
"@jimp/js-png": "1.6.1",
"@jimp/plugin-blit": "1.6.1",
"@jimp/types": "1.6.1",
"parse-bmfont-ascii": "^1.0.6",
"parse-bmfont-binary": "^1.0.6",
"parse-bmfont-xml": "^1.1.6",
"simple-xml-to-json": "^1.2.2",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-quantize": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-quantize/-/plugin-quantize-1.6.1.tgz",
"integrity": "sha512-J2En9PLURfP+vwYDtuZ9T8yBW6BWYZBScydAjRiPBmJfEhTcNQqiiQODrZf7EqbbX/Sy5H6dAeRiqkgoV9N6Ww==",
"license": "MIT",
"dependencies": {
"image-q": "^4.0.0",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-resize": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-1.6.1.tgz",
"integrity": "sha512-CLkrtJoIz2HdWnpYiN6p8KYcPc00rCH/SUu6o+lfZL05Q4uhecJlnvXuj9x+U6mDn3ldPmJj6aZqMHuUJzdVqg==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/types": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-rotate": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-1.6.1.tgz",
"integrity": "sha512-nOjVjbbj705B02ksysKnh0POAwEBXZtJ9zQ5qC+X7Tavl3JNn+P3BzQovbBxLPSbUSld6XID9z5ijin4PtOAUg==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/plugin-crop": "1.6.1",
"@jimp/plugin-resize": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-threshold": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-1.6.1.tgz",
"integrity": "sha512-JOKv9F8s6tnVLf4sB/2fF0F339EFnHvgEdFYugO6VhowKLsap0pEZmLyE/DlRnYtIj2RddHZVxVMp/eKJ04l2Q==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/plugin-color": "1.6.1",
"@jimp/plugin-hash": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/types": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/types/-/types-1.6.1.tgz",
"integrity": "sha512-leI7YbveTNi565m910XgIOwXyuu074H5qazAD1357HImJSv2hqxnWXpwxQbadGWZ7goZRYBDZy5lpqud0p7q5w==",
"license": "MIT",
"dependencies": {
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/utils": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-1.6.1.tgz",
"integrity": "sha512-veFPRd93FCnS7AgmCkPgARVGoDRrJ9cm1ujuNyA+UfQ5VKbED2002sm5XfFLFwTsKC8j04heTrwe+tU1dluXOw==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.1",
"tinycolor2": "^1.6.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -718,6 +1154,52 @@
"node": ">= 10"
}
},
"node_modules/@tokenizer/inflate": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
"integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"token-types": "^6.1.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@tokenizer/inflate/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@tokenizer/inflate/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"license": "MIT"
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
@@ -1015,6 +1497,12 @@
"node": ">=8"
}
},
"node_modules/any-base": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz",
"integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==",
"license": "MIT"
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@@ -1062,6 +1550,15 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/await-to-js": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/await-to-js/-/await-to-js-3.0.0.tgz",
"integrity": "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1122,6 +1619,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bmp-ts": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/bmp-ts/-/bmp-ts-1.0.9.tgz",
"integrity": "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@@ -1570,6 +2073,11 @@
"node": ">= 0.6"
}
},
"node_modules/exif-parser": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz",
"integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="
},
"node_modules/express": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
@@ -1672,6 +2180,24 @@
"node": ">=16"
}
},
"node_modules/file-type": {
"version": "21.3.4",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz",
"integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==",
"license": "MIT",
"dependencies": {
"@tokenizer/inflate": "^0.4.1",
"strtok3": "^10.3.4",
"token-types": "^6.1.1",
"uint8array-extras": "^1.4.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -1755,7 +2281,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -1833,6 +2358,16 @@
"node": ">= 0.4"
}
},
"node_modules/gifwrap": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.10.1.tgz",
"integrity": "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==",
"license": "MIT",
"dependencies": {
"image-q": "^4.0.0",
"omggif": "^1.0.10"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -1988,6 +2523,41 @@
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/image-q": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/image-q/-/image-q-4.0.0.tgz",
"integrity": "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==",
"license": "MIT",
"dependencies": {
"@types/node": "16.9.1"
}
},
"node_modules/image-q/node_modules/@types/node": {
"version": "16.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz",
"integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==",
"license": "MIT"
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -2085,6 +2655,56 @@
"node": ">=0.12.0"
}
},
"node_modules/jimp": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/jimp/-/jimp-1.6.1.tgz",
"integrity": "sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/diff": "1.6.1",
"@jimp/js-bmp": "1.6.1",
"@jimp/js-gif": "1.6.1",
"@jimp/js-jpeg": "1.6.1",
"@jimp/js-png": "1.6.1",
"@jimp/js-tiff": "1.6.1",
"@jimp/plugin-blit": "1.6.1",
"@jimp/plugin-blur": "1.6.1",
"@jimp/plugin-circle": "1.6.1",
"@jimp/plugin-color": "1.6.1",
"@jimp/plugin-contain": "1.6.1",
"@jimp/plugin-cover": "1.6.1",
"@jimp/plugin-crop": "1.6.1",
"@jimp/plugin-displace": "1.6.1",
"@jimp/plugin-dither": "1.6.1",
"@jimp/plugin-fisheye": "1.6.1",
"@jimp/plugin-flip": "1.6.1",
"@jimp/plugin-hash": "1.6.1",
"@jimp/plugin-mask": "1.6.1",
"@jimp/plugin-print": "1.6.1",
"@jimp/plugin-quantize": "1.6.1",
"@jimp/plugin-resize": "1.6.1",
"@jimp/plugin-rotate": "1.6.1",
"@jimp/plugin-threshold": "1.6.1",
"@jimp/types": "1.6.1",
"@jimp/utils": "1.6.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/jpeg-js": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
"license": "BSD-3-Clause"
},
"node_modules/jsqr": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
"license": "Apache-2.0"
},
"node_modules/libsodium": {
"version": "0.7.15",
"resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.7.15.tgz",
@@ -2386,6 +3006,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/omggif": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz",
"integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==",
"license": "MIT"
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -2416,6 +3042,34 @@
"wrappy": "1"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse-bmfont-ascii": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz",
"integrity": "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA==",
"license": "MIT"
},
"node_modules/parse-bmfont-binary": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz",
"integrity": "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA==",
"license": "MIT"
},
"node_modules/parse-bmfont-xml": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/parse-bmfont-xml/-/parse-bmfont-xml-1.1.6.tgz",
"integrity": "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA==",
"license": "MIT",
"dependencies": {
"xml-parse-from-string": "^1.0.0",
"xml2js": "^0.5.0"
}
},
"node_modules/parse-cache-control": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz",
@@ -2465,6 +3119,27 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pixelmatch": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.3.0.tgz",
"integrity": "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==",
"license": "ISC",
"dependencies": {
"pngjs": "^6.0.0"
},
"bin": {
"pixelmatch": "bin/pixelmatch"
}
},
"node_modules/pixelmatch/node_modules/pngjs": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
"license": "MIT",
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/play-audio": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/play-audio/-/play-audio-0.5.2.tgz",
@@ -2483,6 +3158,15 @@
"node": ">=16.0.0"
}
},
"node_modules/pngjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"license": "MIT",
"engines": {
"node": ">=14.19.0"
}
},
"node_modules/prism-media": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz",
@@ -2699,6 +3383,15 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sax": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@@ -2855,6 +3548,15 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/simple-xml-to-json": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/simple-xml-to-json/-/simple-xml-to-json-1.2.7.tgz",
"integrity": "sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q==",
"license": "MIT",
"engines": {
"node": ">=20.12.2"
}
},
"node_modules/sodium-native": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.3.tgz",
@@ -2949,6 +3651,22 @@
"node": ">=0.10.0"
}
},
"node_modules/strtok3": {
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
"integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==",
"license": "MIT",
"dependencies": {
"@tokenizer/token": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -2979,6 +3697,12 @@
"node": ">=10"
}
},
"node_modules/tinycolor2": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -3001,6 +3725,24 @@
"node": ">=0.6"
}
},
"node_modules/token-types": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
"integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
"license": "MIT",
"dependencies": {
"@borewit/text-codec": "^0.2.1",
"@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -3166,6 +3908,18 @@
"node": ">= 0.8"
}
},
"node_modules/uint8array-extras": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
"integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici": {
"version": "6.21.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
@@ -3190,6 +3944,15 @@
"node": ">= 0.8"
}
},
"node_modules/utif2": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz",
"integrity": "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.11"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -3273,6 +4036,34 @@
}
}
},
"node_modules/xml-parse-from-string": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz",
"integrity": "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==",
"license": "MIT"
},
"node_modules/xml2js": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@@ -3298,6 +4089,15 @@
"engines": {
"node": ">=6"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -13,9 +13,9 @@
"start": "node dist/index.js"
},
"dependencies": {
"@discordjs/opus": "^0.9.0",
"@discordjs/rest": "^2.2.0",
"@discordjs/voice": "^0.19.0",
"@discordjs/opus": "^0.9.0",
"@prisma/client": "^5.4.2",
"@snazzah/davey": "^0.1.8",
"cookie-parser": "^1.4.6",
@@ -24,10 +24,12 @@
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-session": "^1.17.3",
"ffmpeg-static": "^5.2.0",
"jimp": "^1.6.1",
"jsqr": "^1.4.0",
"libsodium-wrappers": "^0.7.13",
"play-dl": "^1.9.7",
"sodium-native": "^4.0.4",
"ffmpeg-static": "^5.2.0"
"sodium-native": "^4.0.4"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.3",

View File

@@ -0,0 +1,65 @@
import { ChatInputCommandInteraction, EmbedBuilder, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
function formatList(items: { id: string; name: string }[], max = 10): string {
if (!items.length) return 'Keine';
const shown = items.slice(0, max).map((i) => i.name);
const rest = items.length - shown.length;
return shown.join(', ') + (rest > 0 ? ` (+${rest} weitere)` : '');
}
const command: SlashCommand = {
guildOnly: true,
data: new SlashCommandBuilder()
.setName('permissions')
.setDescription('Analysiert Rollen und Berechtigungen auf Sicherheitsrisiken.')
.addSubcommand((sub) => sub.setName('scan').setDescription('Startet die Rechte-Analyse.'))
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
async execute(interaction: ChatInputCommandInteraction) {
if (!interaction.guild) return;
await interaction.deferReply({ ephemeral: true });
const result = await context.permissionScan.scan(interaction.guild);
const embed = new EmbedBuilder()
.setTitle('🔐 Rechte-Scan')
.setColor(result.tooManyAdmins ? 0xdc2626 : 0xf97316)
.setDescription(`Analyse für **${interaction.guild.name}**`)
.addFields(
{ name: 'Admin-Rollen', value: formatList(result.adminRoles), inline: false },
{ name: 'Rollen, die bannen können', value: formatList(result.banRoles), inline: false },
{
name: 'Bots mit gefährlichen Rechten',
value: result.dangerousBots.length
? result.dangerousBots.slice(0, 10).map((b) => `${b.tag}: ${b.perms.join(', ')}`).join('\n')
: 'Keine',
inline: false
},
{ name: 'Öffentliche Kanäle', value: formatList(result.publicChannels), inline: false },
{ name: '@everyone kann schreiben', value: formatList(result.everyoneCanSendChannels), inline: false },
{ name: 'Leere Rollen', value: formatList(result.emptyRoles), inline: false },
{
name: 'Doppelte Rollen (gleiche Rechte)',
value: result.duplicateRoleGroups.length
? result.duplicateRoleGroups.slice(0, 5).map((g) => g.map((r) => r.name).join(' = ')).join('\n')
: 'Keine',
inline: false
},
{ name: 'Nutzlose Rollen (keine Rechte, keine Mitglieder)', value: formatList(result.uselessRoles), inline: false },
{
name: '⚠️ Zu viele Admin-Rechte?',
value: result.tooManyAdmins
? `Ja — ${result.adminRoles.length} Admin-Rolle(n), ${result.adminMemberCount} Mitglied(er) mit Administrator.`
: 'Nein, sieht unauffällig aus.',
inline: false
}
)
.setTimestamp();
context.branding.applyFooter(embed, interaction.guild.id);
await interaction.editReply({ embeds: [embed] });
}
};
export default command;

View File

@@ -0,0 +1,67 @@
import { ChatInputCommandInteraction, EmbedBuilder, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { SlashCommand } from '../../utils/types';
import { settingsStore } from '../../config/state';
import { context } from '../../config/context';
const command: SlashCommand = {
guildOnly: true,
data: new SlashCommandBuilder()
.setName('ticketconfig')
.setDescription('Konfiguriert Rollen und Vorlagen je Ticket-Kategorie.')
.addSubcommandGroup((group) =>
group
.setName('topic')
.setDescription('Verwaltet Ticket-Kategorien')
.addSubcommand((sub) =>
sub
.setName('set')
.setDescription('Setzt Rolle und/oder Fragen-Vorlage für eine Kategorie.')
.addStringOption((opt) => opt.setName('topic').setDescription('Kategorie-Slug (z.B. ban, help, feedback, other)').setRequired(true))
.addRoleOption((opt) => opt.setName('role').setDescription('Rolle, die bei dieser Kategorie gepingt wird'))
.addStringOption((opt) => opt.setName('questions').setDescription('Fragen für die Vorlage, eine pro Zeile (max. 5)'))
)
.addSubcommand((sub) => sub.setName('list').setDescription('Zeigt die aktuelle Konfiguration aller Kategorien.'))
)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild),
async execute(interaction: ChatInputCommandInteraction) {
if (!interaction.guildId) return;
const sub = interaction.options.getSubcommand();
if (sub === 'set') {
const topic = interaction.options.getString('topic', true).trim().toLowerCase();
const role = interaction.options.getRole('role');
const questionsRaw = interaction.options.getString('questions');
const current = settingsStore.get(interaction.guildId)?.ticketConfig?.topics?.[topic] || {};
const questions = questionsRaw
? questionsRaw.split('\n').map((q) => q.trim()).filter(Boolean).slice(0, 5)
: current.questions;
await settingsStore.set(interaction.guildId, {
ticketConfig: { topics: { [topic]: { roleId: role?.id ?? current.roleId, questions } } }
});
await interaction.reply({ content: `Konfiguration für Kategorie \`${topic}\` gespeichert.`, ephemeral: true });
return;
}
if (sub === 'list') {
const topics = settingsStore.get(interaction.guildId)?.ticketConfig?.topics || {};
const entries = Object.entries(topics);
const embed = new EmbedBuilder()
.setTitle('Ticket-Kategorien')
.setColor(context.branding.getColor(interaction.guildId))
.setDescription(
entries.length
? entries
.map(([topic, cfg]) => `**${topic}** — Rolle: ${cfg.roleId ? `<@&${cfg.roleId}>` : 'Standard'} · Fragen: ${cfg.questions?.length ?? 0}`)
.join('\n')
: 'Noch keine Kategorien konfiguriert.'
);
context.branding.applyFooter(embed, interaction.guildId);
await interaction.reply({ embeds: [embed], ephemeral: true });
}
}
};
export default command;

View File

@@ -0,0 +1,40 @@
import { ChannelType, ChatInputCommandInteraction, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { SlashCommand } from '../../utils/types';
import { context } from '../../config/context';
import { PanelType } from '../../services/infoPanelService';
const command: SlashCommand = {
guildOnly: true,
data: new SlashCommandBuilder()
.setName('panel')
.setDescription('Erstellt feste Info-Panels mit Buttons/Dropdowns.')
.addSubcommand((sub) =>
sub
.setName('create')
.setDescription('Erstellt ein neues Panel.')
.addStringOption((opt) =>
opt
.setName('type')
.setDescription('Panel-Typ')
.setRequired(true)
.addChoices(
{ name: 'Regeln', value: 'rules' },
{ name: 'Support', value: 'support' },
{ name: 'Bewerbungen', value: 'bewerbung' },
{ name: 'Partner', value: 'partner' },
{ name: 'Rollen', value: 'rollen' },
{ name: 'Events', value: 'events' },
{ name: 'FAQ', value: 'faq' }
)
)
.addChannelOption((opt) => opt.setName('channel').setDescription('Zielkanal').addChannelTypes(ChannelType.GuildText).setRequired(true))
)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild),
async execute(interaction: ChatInputCommandInteraction) {
const type = interaction.options.getString('type', true) as PanelType;
const channel = interaction.options.getChannel('channel', true);
await context.infoPanels.openModal(interaction, type, channel.id);
}
};
export default command;

View File

@@ -32,6 +32,9 @@ import { PartnerService } from '../services/partnerService';
import { AltAccountService } from '../services/altAccountService';
import { GalleryService } from '../services/galleryService';
import { GrowthService } from '../services/growthService';
import { PermissionScanService } from '../services/permissionScanService';
import { InfoPanelService } from '../services/infoPanelService';
import { ImageModerationService } from '../services/imageModerationService';
const logging = new LoggingService();
const moduleService = new BotModuleService();
@@ -74,7 +77,10 @@ export const context = {
partners: new PartnerService(),
altAccounts: new AltAccountService(),
gallery: new GalleryService(),
growth: new GrowthService()
growth: new GrowthService(),
permissionScan: new PermissionScanService(),
infoPanels: new InfoPanelService(),
imageModeration: new ImageModerationService()
};
birthdayService.setBadgeService(badgeService);

View File

@@ -103,6 +103,13 @@ export interface GuildSettings {
artistRoleId?: string;
lastWinnerAt?: string;
};
imageModerationConfig?: {
enabled?: boolean;
alertOnly?: boolean;
};
ticketConfig?: {
topics?: Record<string, { roleId?: string; questions?: string[] }>;
};
}
class SettingsStore {
@@ -170,6 +177,8 @@ class SettingsStore {
brandingConfig: (row as any).brandingConfig ?? undefined,
partnerConfig: (row as any).partnerConfig ?? undefined,
galleryConfig: (row as any).galleryConfig ?? undefined,
imageModerationConfig: (row as any).imageModerationConfig ?? undefined,
ticketConfig: (row as any).ticketConfig ?? undefined,
supportRoleId: row.supportRoleId ?? undefined
} satisfies GuildSettings;
this.cache.set(row.guildId, this.applyModuleDefaults(cfg));
@@ -237,6 +246,16 @@ class SettingsStore {
if (partial.galleryConfig) {
merged.galleryConfig = { ...(merged.galleryConfig ?? {}), ...partial.galleryConfig };
}
if (partial.imageModerationConfig) {
merged.imageModerationConfig = { ...(merged.imageModerationConfig ?? {}), ...partial.imageModerationConfig };
}
if (partial.ticketConfig) {
merged.ticketConfig = {
...(merged.ticketConfig ?? {}),
...partial.ticketConfig,
topics: { ...(merged.ticketConfig?.topics ?? {}), ...(partial.ticketConfig.topics ?? {}) }
};
}
merged.automodConfig = { ...mergedAutomod, supportLoginConfig: merged.supportLoginConfig ?? mergedAutomod['supportLoginConfig'] };
merged.statuspageEnabled = mergedAutomod.statuspageEnabled;
merged.statuspageConfig = mergedAutomod.statuspageConfig;
@@ -272,6 +291,8 @@ class SettingsStore {
brandingConfig: merged.brandingConfig ?? Prisma.JsonNull,
partnerConfig: merged.partnerConfig ?? Prisma.JsonNull,
galleryConfig: merged.galleryConfig ?? Prisma.JsonNull,
imageModerationConfig: merged.imageModerationConfig ?? Prisma.JsonNull,
ticketConfig: merged.ticketConfig ?? Prisma.JsonNull,
supportRoleId: merged.supportRoleId ?? null
},
create: {
@@ -302,6 +323,8 @@ class SettingsStore {
brandingConfig: merged.brandingConfig ?? Prisma.JsonNull,
partnerConfig: merged.partnerConfig ?? Prisma.JsonNull,
galleryConfig: merged.galleryConfig ?? Prisma.JsonNull,
imageModerationConfig: merged.imageModerationConfig ?? Prisma.JsonNull,
ticketConfig: merged.ticketConfig ?? Prisma.JsonNull,
supportRoleId: merged.supportRoleId ?? null
}
});

View File

@@ -0,0 +1,25 @@
-- AlterTable
ALTER TABLE "GuildSettings" ADD COLUMN "imageModerationConfig" JSONB,
ADD COLUMN "ticketConfig" JSONB;
-- AlterTable
ALTER TABLE "GuildGrowthEvent" ADD COLUMN "inviterId" TEXT,
ADD COLUMN "suspicious" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "InfoPanel" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"messageId" TEXT,
"type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"items" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "InfoPanel_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "InfoPanel_guildId_idx" ON "InfoPanel"("guildId");

View File

@@ -36,6 +36,8 @@ model GuildSettings {
brandingConfig Json?
partnerConfig Json?
galleryConfig Json?
imageModerationConfig Json?
ticketConfig Json?
supportRoleId String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
@@ -442,7 +444,23 @@ model GuildGrowthEvent {
type String
userId String
inviteCode String?
inviterId String?
suspicious Boolean @default(false)
createdAt DateTime @default(now())
@@index([guildId, type, createdAt])
}
model InfoPanel {
id String @id @default(cuid())
guildId String
channelId String
messageId String?
type String
title String
description String?
items Json?
createdAt DateTime @default(now())
@@index([guildId])
}

View File

@@ -12,13 +12,13 @@ const event: EventHandler = {
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);
Promise.all([context.altAccounts.check(member), context.growth.resolveUsedInvite(member.guild)])
.then(([{ score, reasons }, invite]) => {
const suspicious = context.altAccounts.isSuspicious(score);
if (suspicious) context.logging.logSuspiciousJoin(member, reasons);
context.growth.recordJoin(member.guild.id, member.id, invite, suspicious);
})
.catch(() => undefined);
const guildConfig = settingsStore.get(member.guild.id);
const welcomeCfg = guildConfig?.welcomeConfig || guildConfig?.automodConfig?.welcomeConfig;

View File

@@ -49,6 +49,10 @@ const event: EventHandler = {
await context.gallery.handleComponent(interaction);
return;
}
if (interaction.customId.startsWith('panel:')) {
await context.infoPanels.handleComponent(interaction as any);
return;
}
}
if (interaction.isButton()) {
@@ -87,6 +91,14 @@ const event: EventHandler = {
await context.partners.handleModal(interaction);
return;
}
if (interaction.customId.startsWith('panel:create:')) {
await context.infoPanels.handleModal(interaction);
return;
}
if (interaction.customId.startsWith('ticket:template:')) {
await context.tickets.handleModal(interaction);
return;
}
}
}
};

View File

@@ -11,6 +11,22 @@ const event: EventHandler = {
if (cfg?.automodEnabled === true) context.automod.checkMessage(message, cfg);
if (cfg?.levelingEnabled === true) context.leveling.handleMessage(message);
if (cfg?.badgesEnabled !== false) context.badges.trackMessage(message);
if (cfg?.imageModerationConfig?.enabled === true && message.guild) {
const imageAttachments = message.attachments.filter((a) => a.contentType?.startsWith('image/'));
imageAttachments.forEach((attachment) => {
context.imageModeration.scanAttachment(attachment.url).then((result) => {
if (!result.found) return;
// alertOnly = nur bei erkanntem Scam-Verdacht alarmieren, sonst bei jedem gefundenen QR-Code
if (cfg.imageModerationConfig?.alertOnly === true && !result.flagged) return;
context.logging.logImageAlert(message.guild!, {
userTag: message.author.tag,
channel: message.channel as any,
content: result.content || '',
messageUrl: message.url
});
}).catch(() => undefined);
});
}
// Ticket SLA + KB
await context.tickets.trackFirstResponse(message);
await context.tickets.suggestKnowledgeBase(message);

View File

@@ -2,6 +2,11 @@ import { Guild } from 'discord.js';
import { prisma } from '../database';
import { logger } from '../utils/logger';
interface UsedInvite {
code: string;
inviterId?: string;
}
export class GrowthService {
private inviteCache = new Map<string, Map<string, number>>();
@@ -16,19 +21,19 @@ export class GrowthService {
}
}
public async resolveUsedInvite(guild: Guild): Promise<string | null> {
public async resolveUsedInvite(guild: Guild): Promise<UsedInvite | null> {
const before = this.inviteCache.get(guild.id);
try {
const invites = await guild.invites.fetch();
let usedCode: string | null = null;
let used: UsedInvite | null = null;
invites.forEach((inv) => {
const prevUses = before?.get(inv.code) ?? 0;
if ((inv.uses ?? 0) > prevUses) usedCode = inv.code;
if ((inv.uses ?? 0) > prevUses) used = { code: inv.code, inviterId: inv.inviter?.id };
});
if (!usedCode && before) {
if (!used && before) {
for (const code of before.keys()) {
if (!invites.has(code)) {
usedCode = code;
used = { code };
break;
}
}
@@ -36,15 +41,17 @@ export class GrowthService {
const map = new Map<string, number>();
invites.forEach((inv) => map.set(inv.code, inv.uses ?? 0));
this.inviteCache.set(guild.id, map);
return usedCode;
return used;
} 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 recordJoin(guildId: string, userId: string, invite: UsedInvite | null, suspicious = false) {
await prisma.guildGrowthEvent
.create({ data: { guildId, userId, type: 'join', inviteCode: invite?.code, inviterId: invite?.inviterId, suspicious } })
.catch(() => undefined);
}
public async recordLeave(guildId: string, userId: string) {
@@ -93,4 +100,42 @@ export class GrowthService {
.sort((a, b) => a.day.localeCompare(b.day))
};
}
public async getInviteBreakdown(guild: Guild) {
const since30 = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const [liveInvites, groups, suspiciousGroups] = await Promise.all([
guild.invites.fetch().catch(() => null),
prisma.guildGrowthEvent.groupBy({
by: ['inviteCode', 'inviterId'],
where: { guildId: guild.id, type: 'join', inviteCode: { not: null }, createdAt: { gte: since30 } },
_count: { _all: true }
}),
prisma.guildGrowthEvent.groupBy({
by: ['inviteCode'],
where: { guildId: guild.id, type: 'join', inviteCode: { not: null }, suspicious: true, createdAt: { gte: since30 } },
_count: { _all: true }
})
]);
const suspiciousByCode = new Map(suspiciousGroups.map((g) => [g.inviteCode, g._count._all]));
const usesByCode = new Map((liveInvites?.map((inv) => [inv.code, inv.uses ?? 0]) as [string, number][]) ?? []);
return groups
.map((g) => ({
code: g.inviteCode as string,
inviterId: g.inviterId ?? undefined,
uses: usesByCode.get(g.inviteCode as string) ?? g._count._all,
joins30: g._count._all,
suspiciousJoins30: suspiciousByCode.get(g.inviteCode) ?? 0
}))
.sort((a, b) => b.joins30 - a.joins30);
}
public async getRecentJoins(guildId: string, limit = 20) {
return prisma.guildGrowthEvent.findMany({
where: { guildId, type: 'join' },
orderBy: { createdAt: 'desc' },
take: limit
});
}
}

View File

@@ -0,0 +1,37 @@
import { Jimp } from 'jimp';
import jsQR from 'jsqr';
import { logger } from '../utils/logger';
const SCAM_PATTERNS = [
/discord-?nitro/i,
/free-?nitro/i,
/steamcommunlty/i,
/steamcommunity\.[a-z]{2,}\.[a-z]{2,}/i,
/discordapp-?gift/i,
/dlscord/i,
/dicsord/i
];
export interface ImageScanResult {
found: boolean;
content?: string;
flagged?: boolean;
}
export class ImageModerationService {
public async scanAttachment(url: string): Promise<ImageScanResult> {
try {
const image = await Jimp.read(url);
const { data, width, height } = image.bitmap;
const clamped = new Uint8ClampedArray(data.buffer, data.byteOffset, data.length);
const result = jsQR(clamped, width, height);
if (!result) return { found: false };
const content = result.data;
const flagged = SCAM_PATTERNS.some((p) => p.test(content));
return { found: true, content, flagged };
} catch (err) {
logger.warn(`Image scan failed for ${url}: ${err}`);
return { found: false };
}
}
}

View File

@@ -0,0 +1,161 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonInteraction,
ButtonStyle,
ChatInputCommandInteraction,
Client,
EmbedBuilder,
ModalBuilder,
ModalSubmitInteraction,
StringSelectMenuBuilder,
StringSelectMenuInteraction,
TextInputBuilder,
TextInputStyle
} from 'discord.js';
import { prisma } from '../database';
import { context } from '../config/context';
export type PanelType = 'rules' | 'support' | 'bewerbung' | 'partner' | 'rollen' | 'events' | 'faq';
interface FaqItem {
question: string;
answer: string;
}
export class InfoPanelService {
public async openModal(interaction: ChatInputCommandInteraction, type: PanelType, channelId: string) {
const modal = new ModalBuilder().setCustomId(`panel:create:${type}:${channelId}`).setTitle('Panel erstellen');
const title = new TextInputBuilder().setCustomId('title').setLabel('Titel').setStyle(TextInputStyle.Short).setRequired(true);
const description = new TextInputBuilder().setCustomId('description').setLabel('Beschreibung').setStyle(TextInputStyle.Paragraph).setRequired(type !== 'faq');
const rows = [new ActionRowBuilder<TextInputBuilder>().addComponents(title), new ActionRowBuilder<TextInputBuilder>().addComponents(description)];
if (type === 'faq') {
const items = new TextInputBuilder()
.setCustomId('items')
.setLabel('Fragen (ein "Frage: Antwort" pro Zeile)')
.setStyle(TextInputStyle.Paragraph)
.setPlaceholder('Wie erstelle ich ein Ticket?: Klicke auf den Support-Button.')
.setRequired(true);
rows.push(new ActionRowBuilder<TextInputBuilder>().addComponents(items));
}
modal.addComponents(...rows);
await interaction.showModal(modal);
}
public async handleModal(interaction: ModalSubmitInteraction) {
if (!interaction.customId.startsWith('panel:create:') || !interaction.guildId || !interaction.guild) return;
const [, , type, channelId] = interaction.customId.split(':') as [string, string, PanelType, string];
const title = interaction.fields.getTextInputValue('title');
const description = interaction.fields.getTextInputValue('description') || undefined;
let items: FaqItem[] | undefined;
if (type === 'faq') {
items = interaction.fields
.getTextInputValue('items')
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
.slice(0, 25)
.map((line) => {
const [q, ...rest] = line.split(':');
return { question: (q || '').trim(), answer: rest.join(':').trim() || 'Keine Antwort hinterlegt.' };
})
.filter((i) => i.question);
}
try {
const panel = await this.createPanel(interaction.guildId, channelId, type, title, description, items, interaction.client);
await interaction.reply({ content: `Panel wurde in <#${panel.channelId}> gepostet.`, ephemeral: true });
} catch {
await interaction.reply({ content: 'Zielkanal nicht gefunden.', ephemeral: true });
}
}
public async createPanel(
guildId: string,
channelId: string,
type: PanelType,
title: string,
description: string | undefined,
items: FaqItem[] | undefined,
client: Client
) {
const channel = await client.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isTextBased()) throw new Error('channel not found');
const panel = await prisma.infoPanel.create({
data: { guildId, channelId, type, title, description, items: (items as any) ?? undefined }
});
const { embed, components } = await this.render(panel.id, type, title, description, items, guildId);
const sent = await (channel as any).send({ embeds: [embed], components });
return prisma.infoPanel.update({ where: { id: panel.id }, data: { messageId: sent.id } });
}
public async listPanels(guildId: string) {
return prisma.infoPanel.findMany({ where: { guildId }, orderBy: { createdAt: 'desc' } });
}
public async deletePanel(guildId: string, id: string) {
await prisma.infoPanel.deleteMany({ where: { id, guildId } });
}
public async handleComponent(interaction: ButtonInteraction | StringSelectMenuInteraction) {
if (!interaction.isStringSelectMenu() || !interaction.customId.startsWith('panel:faq:')) return;
const panelId = interaction.customId.split(':')[2];
const panel = await prisma.infoPanel.findUnique({ where: { id: panelId } });
if (!panel) {
await interaction.reply({ content: 'Dieses Panel ist nicht mehr verfügbar.', ephemeral: true });
return;
}
const items = (panel.items as unknown as FaqItem[]) || [];
const index = parseInt(interaction.values[0], 10);
const item = items[index];
await interaction.reply({
content: item ? `**${item.question}**\n${item.answer}` : 'Diese Frage wurde nicht gefunden.',
ephemeral: true
});
}
private async render(panelId: string, type: PanelType, title: string, description: string | undefined, items: FaqItem[] | undefined, guildId: string) {
const embed = new EmbedBuilder().setTitle(title).setColor(context.branding.getColor(guildId));
if (description) embed.setDescription(description);
context.branding.applyFooter(embed, guildId);
const components: ActionRowBuilder<any>[] = [];
if (type === 'faq' && items?.length) {
const select = new StringSelectMenuBuilder()
.setCustomId(`panel:faq:${panelId}`)
.setPlaceholder('Frage auswählen')
.addOptions(items.slice(0, 25).map((item, idx) => ({ label: item.question.slice(0, 100), value: String(idx) })));
components.push(new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select));
} else if (type === 'support') {
components.push(
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId('ticket:create:other').setLabel('Ticket erstellen').setEmoji('🎫').setStyle(ButtonStyle.Primary)
)
);
} else if (type === 'bewerbung') {
const forms = await context.register.listForms(guildId);
const active = forms.find((f) => f.isActive);
if (active) {
components.push(
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId(`register:form:${active.id}`).setLabel('Bewerben').setEmoji('📝').setStyle(ButtonStyle.Primary)
)
);
}
} else if (type === 'partner') {
components.push(
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId('partner:apply').setLabel('Partner werden').setEmoji('🤝').setStyle(ButtonStyle.Primary)
)
);
}
return { embed, components };
}
}

View File

@@ -241,6 +241,27 @@ export class LoggingService {
adminSink?.pushGuildLog({ guildId: guild.id, level: 'INFO', message: `Partner: ${message}`, timestamp: Date.now(), category: 'system' });
}
logImageAlert(guild: Guild, options: { userTag: string; channel?: GuildBasedChannel | null; content: string; messageUrl?: string }) {
const { channel } = this.resolve(guild);
if (!channel) return;
const embed = new EmbedBuilder()
.setTitle('🖼️ QR-Code in Bild erkannt')
.setDescription(`${options.userTag} in ${options.channel ? `<#${options.channel.id}>` : 'einem Kanal'}`)
.addFields({ name: 'Inhalt', value: this.safeField(options.content) })
.setColor(0xdc2626)
.setTimestamp();
if (options.messageUrl) embed.addFields({ name: 'Link', value: options.messageUrl });
context.branding.applyFooter(embed, guild.id);
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log image alert', err));
adminSink?.pushGuildLog({
guildId: guild.id,
level: 'WARN',
message: `Bild-Alarm: ${options.userTag} (QR-Code erkannt)`,
timestamp: Date.now(),
category: 'automodActions'
});
}
logRoleUpdate(member: GuildMember, added: string[], removed: string[]) {
const guildId = member.guild.id;
adminSink?.pushGuildLog({

View File

@@ -0,0 +1,102 @@
import { ChannelType, Guild, PermissionFlagsBits, Role, TextChannel } from 'discord.js';
const DANGEROUS_BOT_PERMS: { flag: bigint; label: string }[] = [
{ flag: PermissionFlagsBits.Administrator, label: 'Administrator' },
{ flag: PermissionFlagsBits.BanMembers, label: 'Bannen' },
{ flag: PermissionFlagsBits.KickMembers, label: 'Kicken' },
{ flag: PermissionFlagsBits.ManageGuild, label: 'Server verwalten' },
{ flag: PermissionFlagsBits.ManageRoles, label: 'Rollen verwalten' },
{ flag: PermissionFlagsBits.ManageChannels, label: 'Kanäle verwalten' },
{ flag: PermissionFlagsBits.ManageWebhooks, label: 'Webhooks verwalten' }
];
interface NamedEntry {
id: string;
name: string;
}
export interface PermissionScanResult {
adminRoles: NamedEntry[];
banRoles: NamedEntry[];
dangerousBots: { id: string; tag: string; perms: string[] }[];
publicChannels: NamedEntry[];
everyoneCanSendChannels: NamedEntry[];
emptyRoles: NamedEntry[];
duplicateRoleGroups: NamedEntry[][];
uselessRoles: NamedEntry[];
tooManyAdmins: boolean;
adminMemberCount: number;
}
export class PermissionScanService {
public async scan(guild: Guild): Promise<PermissionScanResult> {
await guild.members.fetch().catch(() => undefined);
const everyone = guild.roles.everyone;
const roles: Role[] = Array.from(guild.roles.cache.values()).filter((r) => r.id !== guild.id);
const adminRoles = roles.filter((r) => r.permissions.has(PermissionFlagsBits.Administrator)).map((r) => ({ id: r.id, name: r.name }));
const banRoles = roles.filter((r) => r.permissions.has(PermissionFlagsBits.BanMembers)).map((r) => ({ id: r.id, name: r.name }));
const dangerousBots = Array.from(guild.members.cache.values())
.filter((m) => m.user.bot)
.map((m) => ({
id: m.id,
tag: m.user.tag,
perms: DANGEROUS_BOT_PERMS.filter((p) => m.permissions.has(p.flag)).map((p) => p.label)
}))
.filter((b) => b.perms.length > 0);
const publicChannels: NamedEntry[] = [];
const everyoneCanSendChannels: NamedEntry[] = [];
guild.channels.cache
.filter((c) => c.type === ChannelType.GuildText)
.forEach((channel) => {
const c = channel as TextChannel;
const perms = c.permissionsFor(everyone);
if (!perms) return;
if (perms.has(PermissionFlagsBits.ViewChannel)) {
publicChannels.push({ id: c.id, name: c.name });
if (perms.has(PermissionFlagsBits.SendMessages)) everyoneCanSendChannels.push({ id: c.id, name: c.name });
}
});
const emptyRoles = roles.filter((r) => !r.managed && r.members.size === 0).map((r) => ({ id: r.id, name: r.name }));
const byBitfield = new Map<string, Role[]>();
roles
.filter((r) => !r.managed && r.permissions.bitfield !== 0n)
.forEach((r) => {
const key = r.permissions.bitfield.toString();
const arr = byBitfield.get(key) || [];
arr.push(r);
byBitfield.set(key, arr);
});
const duplicateRoleGroups = Array.from(byBitfield.values())
.filter((g) => g.length > 1)
.map((g) => g.map((r) => ({ id: r.id, name: r.name })));
const uselessRoles = roles
.filter((r) => !r.managed && r.permissions.bitfield === 0n && r.members.size === 0)
.map((r) => ({ id: r.id, name: r.name }));
const adminMemberIds = new Set<string>();
guild.members.cache.forEach((m) => {
if (m.permissions.has(PermissionFlagsBits.Administrator)) adminMemberIds.add(m.id);
});
const memberCount = guild.memberCount || guild.members.cache.size;
const tooManyAdmins = adminRoles.length > 3 || (memberCount > 0 && adminMemberIds.size / memberCount > 0.1);
return {
adminRoles,
banRoles,
dangerousBots,
publicChannels,
everyoneCanSendChannels,
emptyRoles,
duplicateRoleGroups,
uselessRoles,
tooManyAdmins,
adminMemberCount: adminMemberIds.size
};
}
}

View File

@@ -9,8 +9,12 @@ import {
EmbedBuilder,
Guild,
GuildMember,
ModalBuilder,
ModalSubmitInteraction,
PermissionsBitField,
TextChannel,
TextInputBuilder,
TextInputStyle,
Client,
Message
} from 'discord.js';
@@ -88,6 +92,21 @@ export class TicketService {
return;
}
const questions = settingsStore.get(interaction.guild.id)?.ticketConfig?.topics?.[topic]?.questions;
if (questions?.length) {
const modal = new ModalBuilder().setCustomId(`ticket:template:${topic}`).setTitle('Angaben zum Ticket');
questions.slice(0, 5).forEach((q, i) => {
const input = new TextInputBuilder()
.setCustomId(`q${i}`)
.setLabel(q.slice(0, 45))
.setStyle(TextInputStyle.Paragraph)
.setRequired(true);
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(input));
});
await interaction.showModal(modal);
return;
}
const record = await this.openTicket(interaction.guild, interaction.member as GuildMember, topic);
await interaction.reply({
content: record ? 'Ticket erstellt! Schau im neuen Kanal nach.' : 'Ticket konnte nicht erstellt werden.',
@@ -156,6 +175,39 @@ export class TicketService {
}
}
public async handleModal(interaction: ModalSubmitInteraction) {
if (!interaction.customId.startsWith('ticket:template:') || !interaction.guild) return;
const topic = interaction.customId.split(':')[2] || 'allgemein';
const existing = await prisma.ticket.findFirst({
where: { userId: interaction.user.id, guildId: interaction.guild.id, status: { notIn: ['closed', 'erledigt'] } }
});
if (existing) {
await interaction.reply({ content: 'Du hast bereits ein offenes Ticket. Bitte schließe es zuerst.', ephemeral: true });
return;
}
const questions = settingsStore.get(interaction.guild.id)?.ticketConfig?.topics?.[topic]?.questions || [];
const answers = questions.slice(0, 5).map((q, i) => ({ question: q, answer: interaction.fields.getTextInputValue(`q${i}`) }));
const record = await this.openTicket(interaction.guild, interaction.member as GuildMember, topic);
if (record && answers.length) {
const channel = await interaction.guild.channels.fetch(record.channelId).catch(() => null);
if (channel && channel.isTextBased()) {
const embed = new EmbedBuilder()
.setTitle('Angaben zum Ticket')
.setColor(context.branding.getColor(interaction.guild.id))
.addFields(answers.map((a) => ({ name: a.question.slice(0, 256), value: a.answer.slice(0, 1024) || '-' })));
await (channel as any).send({ embeds: [embed] }).catch(() => undefined);
}
}
await interaction.reply({
content: record ? 'Ticket erstellt! Schau im neuen Kanal nach.' : 'Ticket konnte nicht erstellt werden.',
ephemeral: true
});
}
public async claimTicket(interaction: ChatInputCommandInteraction) {
const channel = interaction.channel as TextChannel;
const ticket = await prisma.ticket.findFirst({ where: { channelId: channel.id } });
@@ -247,7 +299,9 @@ export class TicketService {
private async openTicket(guild: Guild, member: GuildMember, topic: string): Promise<TicketRecord | null> {
if (!this.isEnabled(guild.id)) return null;
const category = await this.ensureCategory(guild);
const supportRoleId = settingsStore.get(guild.id)?.supportRoleId || env.supportRoleId || null;
const guildCfg = settingsStore.get(guild.id);
const topicRoleId = guildCfg?.ticketConfig?.topics?.[topic]?.roleId;
const supportRoleId = topicRoleId || guildCfg?.supportRoleId || env.supportRoleId || null;
const overwrites: any[] = [
{
id: guild.id,

View File

@@ -102,6 +102,75 @@ router.get('/growth', requireAuth, async (req, res) => {
res.json({ stats });
});
router.get('/growth/invites', 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 [breakdown, recentJoins] = await Promise.all([context.growth.getInviteBreakdown(guild), context.growth.getRecentJoins(guildId)]);
res.json({ breakdown, recentJoins });
});
router.get('/permissions/scan', 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 result = await context.permissionScan.scan(guild);
res.json({ result });
});
router.get('/panels', 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 panels = await context.infoPanels.listPanels(guildId);
res.json({ panels });
});
router.post('/panels', requireAuth, async (req, res) => {
const { guildId, channelId, type, title, description, items } = req.body || {};
if (!guildId || !channelId || !type || !title) return res.status(400).json({ error: 'guildId, channelId, type, title required' });
if (!context.client) return res.status(503).json({ error: 'bot not ready' });
try {
const panel = await context.infoPanels.createPanel(guildId, channelId, type, title, description || undefined, items || undefined, context.client);
res.json({ panel });
} catch {
res.status(404).json({ error: 'Zielkanal nicht gefunden' });
}
});
router.delete('/panels/:id', 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' });
await context.infoPanels.deletePanel(guildId, req.params.id);
res.json({ ok: true });
});
router.get('/ticketconfig', 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 topics = settingsStore.get(guildId)?.ticketConfig?.topics || {};
res.json({ topics });
});
router.post('/ticketconfig', requireAuth, async (req, res) => {
const { guildId, topic, roleId, questions } = req.body || {};
if (!guildId || !topic) return res.status(400).json({ error: 'guildId, topic required' });
const key = String(topic).trim().toLowerCase();
const current = settingsStore.get(guildId)?.ticketConfig?.topics?.[key] || {};
await settingsStore.set(guildId, {
ticketConfig: {
topics: {
[key]: {
roleId: roleId !== undefined ? (roleId || undefined) : current.roleId,
questions: Array.isArray(questions) ? questions.filter(Boolean).slice(0, 5) : current.questions
}
}
}
});
res.json({ ok: true });
});
router.get('/guild/activity', requireAuth, (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
@@ -959,7 +1028,13 @@ router.post('/settings', requireAuth, async (req, res) => {
registerEnabled,
registerConfig,
serverStatsEnabled,
serverStatsConfig
serverStatsConfig,
lockdownConfig,
brandingConfig,
partnerConfig,
galleryConfig,
imageModerationConfig,
ticketConfig
} = req.body;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const normalizeArray = (val: any) =>
@@ -1097,7 +1172,13 @@ router.post('/settings', requireAuth, async (req, res) => {
registerEnabled: parsedRegister.enabled,
registerConfig: parsedRegister,
serverStatsEnabled: typeof serverStatsEnabled === 'string' ? serverStatsEnabled === 'true' : serverStatsEnabled,
serverStatsConfig: serverStatsConfig
serverStatsConfig: serverStatsConfig,
lockdownConfig: lockdownConfig ?? (current as any).lockdownConfig,
brandingConfig: brandingConfig ?? (current as any).brandingConfig,
partnerConfig: partnerConfig ?? (current as any).partnerConfig,
galleryConfig: galleryConfig ?? (current as any).galleryConfig,
imageModerationConfig: imageModerationConfig ?? (current as any).imageModerationConfig,
ticketConfig: ticketConfig ?? (current as any).ticketConfig
});
// Live update logging target
context.logging = new LoggingService(updated.logChannelId);