diff --git a/apps/webui/src/app/api/owner/guilds/route.ts b/apps/webui/src/app/api/owner/guilds/route.ts
index d582ff4..6d7b909 100644
--- a/apps/webui/src/app/api/owner/guilds/route.ts
+++ b/apps/webui/src/app/api/owner/guilds/route.ts
@@ -4,27 +4,39 @@ import { toApiErrorResponse } from '@/lib/auth';
import { env } from '@/lib/env';
import { writeOwnerAudit } from '@/lib/owner-audit';
import { requireOwner } from '@/lib/owner-auth';
+import { createOwnerGuildInvite, enrichOwnerGuildListItems } from '@/lib/owner-guilds';
import { prisma } from '@/lib/prisma';
export async function GET(request: Request) {
try {
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({
- where: q ? { id: { contains: q } } : undefined,
include: { settings: true },
orderBy: { createdAt: 'desc' },
- take: 100
+ take: 200
});
const blacklist = await prisma.guildBlacklist.findMany();
const blacklisted = new Set(blacklist.map((entry) => entry.guildId));
- return NextResponse.json({
- guilds: guilds.map((guild) => ({
+ const enriched = await enrichOwnerGuildListItems(
+ guilds.map((guild) => ({
id: guild.id,
locale: guild.settings?.locale ?? 'de',
createdAt: guild.createdAt.toISOString(),
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
});
} catch (error) {
@@ -34,12 +46,31 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
try {
- const session = await requireOwner('ADMIN');
const body = (await request.json()) as { action?: string; guildId?: string; reason?: string };
if (!body.guildId || !/^\d{17,20}$/.test(body.guildId)) {
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') {
const response = await fetch(`https://discord.com/api/v10/users/@me/guilds/${body.guildId}`, {
method: 'DELETE',
diff --git a/apps/webui/src/app/owner/guilds/page.tsx b/apps/webui/src/app/owner/guilds/page.tsx
index 7055a53..37d8bf2 100644
--- a/apps/webui/src/app/owner/guilds/page.tsx
+++ b/apps/webui/src/app/owner/guilds/page.tsx
@@ -1,5 +1,6 @@
import { GuildsManager } from '@/components/owner/owner-forms';
import { getLocale, t } from '@/lib/i18n';
+import { enrichOwnerGuildListItems } from '@/lib/owner-guilds';
import { prisma } from '@/lib/prisma';
export default async function OwnerGuildsPage() {
@@ -13,6 +14,14 @@ export default async function OwnerGuildsPage() {
prisma.guildBlacklist.findMany()
]);
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 (
@@ -20,14 +29,7 @@ export default async function OwnerGuildsPage() {
{t(locale, 'owner.guilds.title')}
{t(locale, 'owner.guilds.subtitle')}
- ({
- id: guild.id,
- locale: guild.settings?.locale ?? 'de',
- createdAt: guild.createdAt.toISOString(),
- blacklisted: blacklisted.has(guild.id)
- }))}
- />
+
);
}
diff --git a/apps/webui/src/components/owner/owner-forms.tsx b/apps/webui/src/components/owner/owner-forms.tsx
index 2d5907f..89517be 100644
--- a/apps/webui/src/components/owner/owner-forms.tsx
+++ b/apps/webui/src/components/owner/owner-forms.tsx
@@ -3,9 +3,10 @@
import { useRouter } from 'next/navigation';
import { useMemo, useState, useTransition } from 'react';
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 { SettingsForm } from '@/components/settings/settings-form';
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
@@ -195,16 +196,23 @@ export function UsersManager({
export function GuildsManager({
initialGuilds
}: {
- initialGuilds: Array<{ id: string; locale: string; createdAt: string; blacklisted: boolean }>;
+ initialGuilds: OwnerGuildListItem[];
}) {
const t = useTranslations();
const router = useRouter();
const [query, setQuery] = useState('');
const [pending, startTransition] = useTransition();
- const filtered = useMemo(
- () => initialGuilds.filter((guild) => guild.id.includes(query.trim())),
- [initialGuilds, query]
- );
+ const filtered = useMemo(() => {
+ const needle = query.trim().toLowerCase();
+ 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) {
const response = await fetch('/api/owner/guilds', {
@@ -215,6 +223,7 @@ export function GuildsManager({
if (!response.ok) {
throw new Error(await readError(response));
}
+ return (await response.json()) as { ok?: boolean; url?: string };
}
return (
@@ -225,58 +234,103 @@ export function GuildsManager({
{filtered.length === 0 ? (
{t('owner.common.empty')}
) : (
- filtered.map((guild) => (
-
-
-
{guild.id}
-
- {guild.locale} · {new Date(guild.createdAt).toLocaleDateString()}
- {guild.blacklisted ? ` · ${t('owner.guilds.blacklisted')}` : ''}
-
+ filtered.map((guild) => {
+ const displayName = guild.name ?? t('owner.guilds.unknownName');
+ const initials = displayName.slice(0, 2).toUpperCase();
+ return (
+
+
+
+ {guild.iconUrl ? : null}
+ {initials}
+
+
+
{displayName}
+
+ {guild.id}
+ {' · '}
+ {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')}` : ''}
+
+
+
+
+
+
+
+
-
-
-
-
-
- ))
+ );
+ })
)}
diff --git a/apps/webui/src/lib/discord-guild-resources.ts b/apps/webui/src/lib/discord-guild-resources.ts
index fbd96a3..e67b836 100644
--- a/apps/webui/src/lib/discord-guild-resources.ts
+++ b/apps/webui/src/lib/discord-guild-resources.ts
@@ -6,14 +6,26 @@ import { redis } from './redis';
const DISCORD_API_BASE = 'https://discord.com/api/v10';
const CACHE_TTL_SECONDS = 60;
-async function discordBotFetch
(path: string): Promise {
+export async function discordBotFetch(
+ path: string,
+ init?: { method?: string; body?: unknown }
+): Promise {
+ const method = init?.method ?? 'GET';
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'
});
if (!response.ok) {
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;
}
diff --git a/apps/webui/src/lib/owner-guilds.ts b/apps/webui/src/lib/owner-guilds.ts
new file mode 100644
index 0000000..10d969d
--- /dev/null
+++ b/apps/webui/src/lib/owner-guilds.ts
@@ -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 {
+ 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(`/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(items: T[], concurrency: number, fn: (item: T) => Promise): Promise {
+ const results = new Array(items.length);
+ let nextIndex = 0;
+
+ async function worker(): Promise {
+ 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 {
+ 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(`/guilds/${guildId}?with_counts=true`);
+ const channels = await discordBotFetch(`/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(`/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');
+}
diff --git a/apps/webui/src/messages/de.json b/apps/webui/src/messages/de.json
index 6a76f60..142caf8 100644
--- a/apps/webui/src/messages/de.json
+++ b/apps/webui/src/messages/de.json
@@ -990,7 +990,13 @@
"guilds": {
"title": "Server-Verwaltung",
"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",
"blacklist": "Blacklisten",
"unblacklist": "Blacklist entfernen",
diff --git a/apps/webui/src/messages/en.json b/apps/webui/src/messages/en.json
index f8b2d74..1fb186a 100644
--- a/apps/webui/src/messages/en.json
+++ b/apps/webui/src/messages/en.json
@@ -990,7 +990,13 @@
"guilds": {
"title": "Guild management",
"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",
"blacklist": "Blacklist",
"unblacklist": "Remove blacklist",
diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md
index 4d420ce..0fbcf18 100644
--- a/docs/PHASE-TRACKING.md
+++ b/docs/PHASE-TRACKING.md
@@ -306,6 +306,20 @@
- [ ] User-Autorollen gesetzt, Bot-Rolle über Autorolle, Manage Roles → Rolle bei Join
- [ ] 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)
### Abgeschlossen (Code)
diff --git a/packages/shared/src/owner.ts b/packages/shared/src/owner.ts
index 33158e5..ea36d96 100644
--- a/packages/shared/src/owner.ts
+++ b/packages/shared/src/owner.ts
@@ -79,6 +79,19 @@ export const GuildBlacklistSchema = z.object({
export type GuildBlacklist = z.infer;
+/** 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;
+
export const ChangelogEntrySchema = z.object({
id: z.string().optional(),
version: z.string().min(1).max(32),