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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
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');
|
||||
}
|
||||
Reference in New Issue
Block a user