deploy #2
@@ -4,27 +4,39 @@ import { toApiErrorResponse } from '@/lib/auth';
|
|||||||
import { env } from '@/lib/env';
|
import { env } from '@/lib/env';
|
||||||
import { writeOwnerAudit } from '@/lib/owner-audit';
|
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||||
import { requireOwner } from '@/lib/owner-auth';
|
import { requireOwner } from '@/lib/owner-auth';
|
||||||
|
import { createOwnerGuildInvite, enrichOwnerGuildListItems } from '@/lib/owner-guilds';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
await requireOwner('VIEWER');
|
await requireOwner('VIEWER');
|
||||||
const q = new URL(request.url).searchParams.get('q')?.trim() ?? '';
|
const q = new URL(request.url).searchParams.get('q')?.trim().toLowerCase() ?? '';
|
||||||
const guilds = await prisma.guild.findMany({
|
const guilds = await prisma.guild.findMany({
|
||||||
where: q ? { id: { contains: q } } : undefined,
|
|
||||||
include: { settings: true },
|
include: { settings: true },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 100
|
take: 200
|
||||||
});
|
});
|
||||||
const blacklist = await prisma.guildBlacklist.findMany();
|
const blacklist = await prisma.guildBlacklist.findMany();
|
||||||
const blacklisted = new Set(blacklist.map((entry) => entry.guildId));
|
const blacklisted = new Set(blacklist.map((entry) => entry.guildId));
|
||||||
return NextResponse.json({
|
const enriched = await enrichOwnerGuildListItems(
|
||||||
guilds: guilds.map((guild) => ({
|
guilds.map((guild) => ({
|
||||||
id: guild.id,
|
id: guild.id,
|
||||||
locale: guild.settings?.locale ?? 'de',
|
locale: guild.settings?.locale ?? 'de',
|
||||||
createdAt: guild.createdAt.toISOString(),
|
createdAt: guild.createdAt.toISOString(),
|
||||||
blacklisted: blacklisted.has(guild.id)
|
blacklisted: blacklisted.has(guild.id)
|
||||||
})),
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const filtered =
|
||||||
|
q.length === 0
|
||||||
|
? enriched
|
||||||
|
: enriched.filter(
|
||||||
|
(guild) =>
|
||||||
|
guild.id.includes(q) || (guild.name?.toLowerCase().includes(q) ?? false)
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
guilds: filtered,
|
||||||
blacklist
|
blacklist
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -34,12 +46,31 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const session = await requireOwner('ADMIN');
|
|
||||||
const body = (await request.json()) as { action?: string; guildId?: string; reason?: string };
|
const body = (await request.json()) as { action?: string; guildId?: string; reason?: string };
|
||||||
if (!body.guildId || !/^\d{17,20}$/.test(body.guildId)) {
|
if (!body.guildId || !/^\d{17,20}$/.test(body.guildId)) {
|
||||||
return NextResponse.json({ error: 'Invalid guildId' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid guildId' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (body.action === 'createInvite') {
|
||||||
|
const session = await requireOwner('SUPPORT');
|
||||||
|
try {
|
||||||
|
const invite = await createOwnerGuildInvite(body.guildId);
|
||||||
|
await writeOwnerAudit(prisma, {
|
||||||
|
actorUserId: session.user.id,
|
||||||
|
action: 'guilds.createInvite',
|
||||||
|
targetType: 'Guild',
|
||||||
|
targetId: body.guildId,
|
||||||
|
after: invite
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true, ...invite });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Invite creation failed';
|
||||||
|
return NextResponse.json({ error: message }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await requireOwner('ADMIN');
|
||||||
|
|
||||||
if (body.action === 'leave') {
|
if (body.action === 'leave') {
|
||||||
const response = await fetch(`https://discord.com/api/v10/users/@me/guilds/${body.guildId}`, {
|
const response = await fetch(`https://discord.com/api/v10/users/@me/guilds/${body.guildId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { GuildsManager } from '@/components/owner/owner-forms';
|
import { GuildsManager } from '@/components/owner/owner-forms';
|
||||||
import { getLocale, t } from '@/lib/i18n';
|
import { getLocale, t } from '@/lib/i18n';
|
||||||
|
import { enrichOwnerGuildListItems } from '@/lib/owner-guilds';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
export default async function OwnerGuildsPage() {
|
export default async function OwnerGuildsPage() {
|
||||||
@@ -13,6 +14,14 @@ export default async function OwnerGuildsPage() {
|
|||||||
prisma.guildBlacklist.findMany()
|
prisma.guildBlacklist.findMany()
|
||||||
]);
|
]);
|
||||||
const blacklisted = new Set(blacklist.map((entry) => entry.guildId));
|
const blacklisted = new Set(blacklist.map((entry) => entry.guildId));
|
||||||
|
const initialGuilds = await enrichOwnerGuildListItems(
|
||||||
|
guilds.map((guild) => ({
|
||||||
|
id: guild.id,
|
||||||
|
locale: guild.settings?.locale ?? 'de',
|
||||||
|
createdAt: guild.createdAt.toISOString(),
|
||||||
|
blacklisted: blacklisted.has(guild.id)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -20,14 +29,7 @@ export default async function OwnerGuildsPage() {
|
|||||||
<h1 className="text-2xl font-semibold">{t(locale, 'owner.guilds.title')}</h1>
|
<h1 className="text-2xl font-semibold">{t(locale, 'owner.guilds.title')}</h1>
|
||||||
<p className="text-sm text-muted-foreground">{t(locale, 'owner.guilds.subtitle')}</p>
|
<p className="text-sm text-muted-foreground">{t(locale, 'owner.guilds.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<GuildsManager
|
<GuildsManager initialGuilds={initialGuilds} />
|
||||||
initialGuilds={guilds.map((guild) => ({
|
|
||||||
id: guild.id,
|
|
||||||
locale: guild.settings?.locale ?? 'de',
|
|
||||||
createdAt: guild.createdAt.toISOString(),
|
|
||||||
blacklisted: blacklisted.has(guild.id)
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useMemo, useState, useTransition } from 'react';
|
import { useMemo, useState, useTransition } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { BotPresenceConfig, OwnerRole } from '@nexumi/shared';
|
import type { BotPresenceConfig, OwnerGuildListItem, OwnerRole } from '@nexumi/shared';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { SettingsForm } from '@/components/settings/settings-form';
|
import { SettingsForm } from '@/components/settings/settings-form';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -195,16 +196,23 @@ export function UsersManager({
|
|||||||
export function GuildsManager({
|
export function GuildsManager({
|
||||||
initialGuilds
|
initialGuilds
|
||||||
}: {
|
}: {
|
||||||
initialGuilds: Array<{ id: string; locale: string; createdAt: string; blacklisted: boolean }>;
|
initialGuilds: OwnerGuildListItem[];
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
const filtered = useMemo(
|
const filtered = useMemo(() => {
|
||||||
() => initialGuilds.filter((guild) => guild.id.includes(query.trim())),
|
const needle = query.trim().toLowerCase();
|
||||||
[initialGuilds, query]
|
if (!needle) {
|
||||||
);
|
return initialGuilds;
|
||||||
|
}
|
||||||
|
return initialGuilds.filter(
|
||||||
|
(guild) =>
|
||||||
|
guild.id.includes(needle) ||
|
||||||
|
(guild.name?.toLowerCase().includes(needle) ?? false)
|
||||||
|
);
|
||||||
|
}, [initialGuilds, query]);
|
||||||
|
|
||||||
async function postAction(action: string, guildId: string, reason?: string) {
|
async function postAction(action: string, guildId: string, reason?: string) {
|
||||||
const response = await fetch('/api/owner/guilds', {
|
const response = await fetch('/api/owner/guilds', {
|
||||||
@@ -215,6 +223,7 @@ export function GuildsManager({
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await readError(response));
|
throw new Error(await readError(response));
|
||||||
}
|
}
|
||||||
|
return (await response.json()) as { ok?: boolean; url?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -225,58 +234,103 @@ export function GuildsManager({
|
|||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<p className="p-4 text-sm text-muted-foreground">{t('owner.common.empty')}</p>
|
<p className="p-4 text-sm text-muted-foreground">{t('owner.common.empty')}</p>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((guild) => (
|
filtered.map((guild) => {
|
||||||
<div key={guild.id} className="flex flex-col gap-3 px-4 py-3 text-sm sm:flex-row sm:items-center sm:justify-between">
|
const displayName = guild.name ?? t('owner.guilds.unknownName');
|
||||||
<div>
|
const initials = displayName.slice(0, 2).toUpperCase();
|
||||||
<p className="font-medium">{guild.id}</p>
|
return (
|
||||||
<p className="text-muted-foreground">
|
<div
|
||||||
{guild.locale} · {new Date(guild.createdAt).toLocaleDateString()}
|
key={guild.id}
|
||||||
{guild.blacklisted ? ` · ${t('owner.guilds.blacklisted')}` : ''}
|
className="flex flex-col gap-3 px-4 py-3 text-sm sm:flex-row sm:items-center sm:justify-between"
|
||||||
</p>
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<Avatar className="h-10 w-10 shrink-0">
|
||||||
|
{guild.iconUrl ? <AvatarImage src={guild.iconUrl} alt="" /> : null}
|
||||||
|
<AvatarFallback>{initials}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium">{displayName}</p>
|
||||||
|
<p className="truncate text-muted-foreground">
|
||||||
|
<span className="font-mono text-xs">{guild.id}</span>
|
||||||
|
{' · '}
|
||||||
|
{guild.memberCount != null
|
||||||
|
? t('owner.guilds.members', { count: String(guild.memberCount) })
|
||||||
|
: t('owner.guilds.membersUnknown')}
|
||||||
|
{' · '}
|
||||||
|
{guild.locale} · {new Date(guild.createdAt).toLocaleDateString()}
|
||||||
|
{guild.blacklisted ? ` · ${t('owner.guilds.blacklisted')}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() =>
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const result = await postAction('createInvite', guild.id);
|
||||||
|
if (result.url) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(result.url);
|
||||||
|
toast.success(t('owner.guilds.inviteCopied', { url: result.url }));
|
||||||
|
} catch {
|
||||||
|
toast.success(t('owner.guilds.inviteCreated', { url: result.url }));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.success(t('common.saveSuccess'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t('common.saveError'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('owner.guilds.createInvite')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() =>
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await postAction(guild.blacklisted ? 'unblacklist' : 'blacklist', guild.id, 'Owner panel');
|
||||||
|
toast.success(t('common.saveSuccess'));
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t('common.saveError'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{guild.blacklisted ? t('owner.guilds.unblacklist') : t('owner.guilds.blacklist')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() =>
|
||||||
|
startTransition(async () => {
|
||||||
|
if (!window.confirm(t('owner.guilds.leaveConfirm'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await postAction('leave', guild.id);
|
||||||
|
toast.success(t('common.saveSuccess'));
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t('common.saveError'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('owner.guilds.leave')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
);
|
||||||
<Button
|
})
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={pending}
|
|
||||||
onClick={() =>
|
|
||||||
startTransition(async () => {
|
|
||||||
try {
|
|
||||||
await postAction(guild.blacklisted ? 'unblacklist' : 'blacklist', guild.id, 'Owner panel');
|
|
||||||
toast.success(t('common.saveSuccess'));
|
|
||||||
router.refresh();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : t('common.saveError'));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{guild.blacklisted ? t('owner.guilds.unblacklist') : t('owner.guilds.blacklist')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="destructive"
|
|
||||||
disabled={pending}
|
|
||||||
onClick={() =>
|
|
||||||
startTransition(async () => {
|
|
||||||
if (!window.confirm(t('owner.guilds.leaveConfirm'))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await postAction('leave', guild.id);
|
|
||||||
toast.success(t('common.saveSuccess'));
|
|
||||||
router.refresh();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : t('common.saveError'));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t('owner.guilds.leave')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -6,14 +6,26 @@ import { redis } from './redis';
|
|||||||
const DISCORD_API_BASE = 'https://discord.com/api/v10';
|
const DISCORD_API_BASE = 'https://discord.com/api/v10';
|
||||||
const CACHE_TTL_SECONDS = 60;
|
const CACHE_TTL_SECONDS = 60;
|
||||||
|
|
||||||
async function discordBotFetch<T>(path: string): Promise<T> {
|
export async function discordBotFetch<T>(
|
||||||
|
path: string,
|
||||||
|
init?: { method?: string; body?: unknown }
|
||||||
|
): Promise<T> {
|
||||||
|
const method = init?.method ?? 'GET';
|
||||||
const response = await fetch(`${DISCORD_API_BASE}${path}`, {
|
const response = await fetch(`${DISCORD_API_BASE}${path}`, {
|
||||||
headers: { Authorization: `Bot ${env.BOT_TOKEN}` },
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bot ${env.BOT_TOKEN}`,
|
||||||
|
...(init?.body !== undefined ? { 'Content-Type': 'application/json' } : {})
|
||||||
|
},
|
||||||
|
body: init?.body !== undefined ? JSON.stringify(init.body) : undefined,
|
||||||
cache: 'no-store'
|
cache: 'no-store'
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Discord API request to ${path} failed with status ${response.status}`);
|
throw new Error(`Discord API request to ${path} failed with status ${response.status}`);
|
||||||
}
|
}
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
return (await response.json()) as T;
|
return (await response.json()) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
145
apps/webui/src/lib/owner-guilds.ts
Normal file
145
apps/webui/src/lib/owner-guilds.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import type { OwnerGuildListItem } from '@nexumi/shared';
|
||||||
|
import { discordBotFetch } from './discord-guild-resources';
|
||||||
|
import { redis } from './redis';
|
||||||
|
|
||||||
|
const META_CACHE_TTL_SECONDS = 300;
|
||||||
|
const INVITE_CHANNEL_TYPES = new Set([0, 2, 5, 13]); // text, voice, announcement, stage
|
||||||
|
|
||||||
|
interface DiscordGuildRest {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string | null;
|
||||||
|
system_channel_id?: string | null;
|
||||||
|
approximate_member_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscordChannelRest {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: number;
|
||||||
|
position?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscordInviteRest {
|
||||||
|
code: string;
|
||||||
|
channel?: { id: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OwnerGuildDiscordMeta = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string | null;
|
||||||
|
iconUrl: string | null;
|
||||||
|
memberCount: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function guildIconUrl(guildId: string, icon: string | null): string | null {
|
||||||
|
if (!icon) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const ext = icon.startsWith('a_') ? 'gif' : 'png';
|
||||||
|
return `https://cdn.discordapp.com/icons/${guildId}/${icon}.${ext}?size=64`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOwnerGuildDiscordMeta(guildId: string): Promise<OwnerGuildDiscordMeta | null> {
|
||||||
|
const cacheKey = `owner:guild:${guildId}:meta:v1`;
|
||||||
|
const cached = await redis.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return JSON.parse(cached) as OwnerGuildDiscordMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const guild = await discordBotFetch<DiscordGuildRest>(`/guilds/${guildId}?with_counts=true`);
|
||||||
|
const meta: OwnerGuildDiscordMeta = {
|
||||||
|
id: guild.id,
|
||||||
|
name: guild.name,
|
||||||
|
icon: guild.icon,
|
||||||
|
iconUrl: guildIconUrl(guild.id, guild.icon),
|
||||||
|
memberCount: guild.approximate_member_count ?? null
|
||||||
|
};
|
||||||
|
await redis.set(cacheKey, JSON.stringify(meta), 'EX', META_CACHE_TTL_SECONDS);
|
||||||
|
return meta;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mapPool<T, R>(items: T[], concurrency: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||||
|
const results = new Array<R>(items.length);
|
||||||
|
let nextIndex = 0;
|
||||||
|
|
||||||
|
async function worker(): Promise<void> {
|
||||||
|
while (nextIndex < items.length) {
|
||||||
|
const index = nextIndex;
|
||||||
|
nextIndex += 1;
|
||||||
|
results[index] = await fn(items[index]!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workers = Array.from({ length: Math.min(concurrency, Math.max(items.length, 1)) }, () => worker());
|
||||||
|
await Promise.all(workers);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enrichOwnerGuildListItems(
|
||||||
|
base: Array<{
|
||||||
|
id: string;
|
||||||
|
locale: string;
|
||||||
|
createdAt: string;
|
||||||
|
blacklisted: boolean;
|
||||||
|
}>
|
||||||
|
): Promise<OwnerGuildListItem[]> {
|
||||||
|
const metas = await mapPool(base, 8, (guild) => getOwnerGuildDiscordMeta(guild.id));
|
||||||
|
return base.map((guild, index) => {
|
||||||
|
const meta = metas[index];
|
||||||
|
return {
|
||||||
|
id: guild.id,
|
||||||
|
name: meta?.name ?? null,
|
||||||
|
iconUrl: meta?.iconUrl ?? null,
|
||||||
|
memberCount: meta?.memberCount ?? null,
|
||||||
|
locale: guild.locale,
|
||||||
|
createdAt: guild.createdAt,
|
||||||
|
blacklisted: guild.blacklisted
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOwnerGuildInvite(guildId: string): Promise<{
|
||||||
|
url: string;
|
||||||
|
code: string;
|
||||||
|
channelId: string;
|
||||||
|
}> {
|
||||||
|
const guild = await discordBotFetch<DiscordGuildRest>(`/guilds/${guildId}?with_counts=true`);
|
||||||
|
const channels = await discordBotFetch<DiscordChannelRest[]>(`/guilds/${guildId}/channels`);
|
||||||
|
const candidates = channels
|
||||||
|
.filter((channel) => INVITE_CHANNEL_TYPES.has(channel.type))
|
||||||
|
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||||||
|
|
||||||
|
const ordered = [
|
||||||
|
...candidates.filter((channel) => channel.id === guild.system_channel_id),
|
||||||
|
...candidates.filter((channel) => channel.id !== guild.system_channel_id)
|
||||||
|
];
|
||||||
|
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
for (const channel of ordered) {
|
||||||
|
try {
|
||||||
|
const invite = await discordBotFetch<DiscordInviteRest>(`/channels/${channel.id}/invites`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
max_age: 86_400,
|
||||||
|
max_uses: 5,
|
||||||
|
unique: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
code: invite.code,
|
||||||
|
url: `https://discord.gg/${invite.code}`,
|
||||||
|
channelId: channel.id
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError ?? new Error('Could not create an invite for this guild');
|
||||||
|
}
|
||||||
@@ -990,7 +990,13 @@
|
|||||||
"guilds": {
|
"guilds": {
|
||||||
"title": "Server-Verwaltung",
|
"title": "Server-Verwaltung",
|
||||||
"subtitle": "Server suchen, blacklisten oder den Bot entfernen.",
|
"subtitle": "Server suchen, blacklisten oder den Bot entfernen.",
|
||||||
"search": "Server-ID suchen…",
|
"search": "Servername oder ID suchen…",
|
||||||
|
"unknownName": "Unbekannter Server",
|
||||||
|
"members": "{count} Mitglieder",
|
||||||
|
"membersUnknown": "Mitglieder unbekannt",
|
||||||
|
"createInvite": "Invite erstellen",
|
||||||
|
"inviteCopied": "Invite kopiert: {url}",
|
||||||
|
"inviteCreated": "Invite erstellt: {url}",
|
||||||
"blacklisted": "Blacklist",
|
"blacklisted": "Blacklist",
|
||||||
"blacklist": "Blacklisten",
|
"blacklist": "Blacklisten",
|
||||||
"unblacklist": "Blacklist entfernen",
|
"unblacklist": "Blacklist entfernen",
|
||||||
|
|||||||
@@ -990,7 +990,13 @@
|
|||||||
"guilds": {
|
"guilds": {
|
||||||
"title": "Guild management",
|
"title": "Guild management",
|
||||||
"subtitle": "Search guilds, blacklist them, or leave with the bot.",
|
"subtitle": "Search guilds, blacklist them, or leave with the bot.",
|
||||||
"search": "Search guild ID…",
|
"search": "Search guild name or ID…",
|
||||||
|
"unknownName": "Unknown guild",
|
||||||
|
"members": "{count} members",
|
||||||
|
"membersUnknown": "Member count unknown",
|
||||||
|
"createInvite": "Create invite",
|
||||||
|
"inviteCopied": "Invite copied: {url}",
|
||||||
|
"inviteCreated": "Invite created: {url}",
|
||||||
"blacklisted": "Blacklisted",
|
"blacklisted": "Blacklisted",
|
||||||
"blacklist": "Blacklist",
|
"blacklist": "Blacklist",
|
||||||
"unblacklist": "Remove blacklist",
|
"unblacklist": "Remove blacklist",
|
||||||
|
|||||||
@@ -306,6 +306,20 @@
|
|||||||
- [ ] User-Autorollen gesetzt, Bot-Rolle über Autorolle, Manage Roles → Rolle bei Join
|
- [ ] User-Autorollen gesetzt, Bot-Rolle über Autorolle, Manage Roles → Rolle bei Join
|
||||||
- [ ] Dashboard Welcome-Embed-Vorschau zeigt eigenen Namen/Avatar und Servername/-icon
|
- [ ] Dashboard Welcome-Embed-Vorschau zeigt eigenen Namen/Avatar und Servername/-icon
|
||||||
|
|
||||||
|
## Post-Phase – Owner Guild-Liste Meta + Invite (Status: implementiert)
|
||||||
|
|
||||||
|
### Abgeschlossen (Code)
|
||||||
|
|
||||||
|
- Owner `/owner/guilds`: Discord-Name, Icon, Member-Count, ID (Live-Meta via Bot-Token, Redis-Cache)
|
||||||
|
- Suche nach Name und ID
|
||||||
|
- Aktion „Invite erstellen“ (Systemkanal zuerst, max. 24h / 5 Uses, Clipboard + Owner-Audit)
|
||||||
|
|
||||||
|
### Manuell testen
|
||||||
|
|
||||||
|
- [ ] Serverliste zeigt Name/Logo/Mitglieder statt nur ID
|
||||||
|
- [ ] Suche nach Servername filtert
|
||||||
|
- [ ] Invite erstellen → Link im Toast/Clipboard; Bot braucht Create Instant Invite
|
||||||
|
|
||||||
## Post-Phase – Automod/Moderation Dashboard Ausbau (Status: implementiert)
|
## Post-Phase – Automod/Moderation Dashboard Ausbau (Status: implementiert)
|
||||||
|
|
||||||
### Abgeschlossen (Code)
|
### Abgeschlossen (Code)
|
||||||
|
|||||||
@@ -79,6 +79,19 @@ export const GuildBlacklistSchema = z.object({
|
|||||||
|
|
||||||
export type GuildBlacklist = z.infer<typeof GuildBlacklistSchema>;
|
export type GuildBlacklist = z.infer<typeof GuildBlacklistSchema>;
|
||||||
|
|
||||||
|
/** Enriched guild row for the Owner panel guild manager. */
|
||||||
|
export const OwnerGuildListItemSchema = z.object({
|
||||||
|
id: snowflake,
|
||||||
|
name: z.string().nullable(),
|
||||||
|
iconUrl: z.string().url().nullable(),
|
||||||
|
memberCount: z.number().int().nonnegative().nullable(),
|
||||||
|
locale: z.string().min(1).max(16),
|
||||||
|
createdAt: z.string(),
|
||||||
|
blacklisted: z.boolean()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OwnerGuildListItem = z.infer<typeof OwnerGuildListItemSchema>;
|
||||||
|
|
||||||
export const ChangelogEntrySchema = z.object({
|
export const ChangelogEntrySchema = z.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
version: z.string().min(1).max(32),
|
version: z.string().min(1).max(32),
|
||||||
|
|||||||
Reference in New Issue
Block a user