Enhance guild management features in the owner panel

- Updated the guilds API to support searching by both guild name and ID, improving user experience.
- Increased the number of guilds retrieved from the database from 100 to 200 for better visibility.
- Implemented invite creation functionality, allowing owners to generate invites directly from the guild management interface.
- Enhanced the UI to display guild names, icons, and member counts, providing a more informative overview.
- Updated localization files to reflect new search capabilities and invite-related messages in both English and German.
This commit is contained in:
smueller
2026-07-24 10:59:07 +02:00
parent 0ebe540c3f
commit 211403d54d
9 changed files with 359 additions and 76 deletions

View File

@@ -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<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}`, {
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;
}

View 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');
}