Enhance bot and WebUI functionality with new features and environment updates

- Added support for new environment variables in `.env.example` for public site and legal operator information.
- Integrated core commands into the bot's command structure for improved functionality.
- Implemented a new `publishBotStatus` function to update bot status in Redis, enhancing monitoring capabilities.
- Updated the landing page to include features, invite links, and support server access, improving user experience.
- Enhanced localization with new keys for core commands and landing page elements in both English and German.
- Improved error handling and logging for bot presence updates and status publishing.
This commit is contained in:
smueller
2026-07-22 16:47:09 +02:00
parent 0b4ac0da29
commit 4852f16f79
32 changed files with 1579 additions and 83 deletions

View File

@@ -25,3 +25,10 @@ WEBUI_URL=http://10.111.0.65:3000
SESSION_SECRET= SESSION_SECRET=
# Comma-separated Discord user IDs treated as global bot owners (owner panel, Phase 7 Batch C). # Comma-separated Discord user IDs treated as global bot owners (owner panel, Phase 7 Batch C).
OWNER_USER_IDS= OWNER_USER_IDS=
# Public site / bot links (Phase 8)
SUPPORT_SERVER_URL=
LEGAL_OPERATOR_NAME=
LEGAL_OPERATOR_ADDRESS=
LEGAL_OPERATOR_EMAIL=
LEGAL_OPERATOR_PHONE=

View File

@@ -29,9 +29,11 @@ import { statsCommands } from './modules/stats/index.js';
import { feedsCommands } from './modules/feeds/index.js'; import { feedsCommands } from './modules/feeds/index.js';
import { scheduleCommands } from './modules/scheduler/index.js'; import { scheduleCommands } from './modules/scheduler/index.js';
import { guildBackupCommands } from './modules/guildbackup/index.js'; import { guildBackupCommands } from './modules/guildbackup/index.js';
import { coreCommands } from './modules/core/index.js';
import type { BotContext, SlashCommand } from './types.js'; import type { BotContext, SlashCommand } from './types.js';
const commands: SlashCommand[] = [ const commands: SlashCommand[] = [
...coreCommands,
...moderationCommands, ...moderationCommands,
...automodCommands, ...automodCommands,
...welcomeCommands, ...welcomeCommands,

View File

@@ -36,7 +36,17 @@ const EnvSchema = z.object({
.filter((id) => id.length > 0) .filter((id) => id.length > 0)
: [] : []
) )
) ),
WEBUI_URL: z.preprocess(emptyToUndefined, z.string().url().optional()),
SUPPORT_SERVER_URL: z.preprocess(emptyToUndefined, z.string().url().optional())
}); });
export const env = EnvSchema.parse(process.env); export const env = EnvSchema.parse(process.env);
export function buildBotInviteUrl(clientId = env.BOT_CLIENT_ID): string {
const url = new URL('https://discord.com/api/oauth2/authorize');
url.searchParams.set('client_id', clientId);
url.searchParams.set('permissions', '8');
url.searchParams.set('scope', 'bot applications.commands');
return url.toString();
}

View File

@@ -120,10 +120,11 @@ if (!isShard) {
logger.error({ error }, 'Failed to register application commands'); logger.error({ error }, 'Failed to register application commands');
} }
try { try {
const { applyBotPresence } = await import('./presence.js'); const { applyBotPresence, publishBotStatus } = await import('./presence.js');
await applyBotPresence(context); await applyBotPresence(context);
await publishBotStatus(context);
} catch (error) { } catch (error) {
logger.warn({ error }, 'Failed to apply initial bot presence'); logger.warn({ error }, 'Failed to apply initial bot presence/status');
} }
}); });

View File

@@ -0,0 +1,24 @@
import { SlashCommandBuilder } from 'discord.js';
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
export const helpCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('help'),
'core.help.description'
).addStringOption((option) =>
applyOptionDescription(option.setName('module'), 'core.help.options.module')
);
export const infoCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('info'),
'core.info.description'
);
export const inviteCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('invite'),
'core.invite.description'
);
export const supportCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('support'),
'core.support.description'
);

View File

@@ -0,0 +1,158 @@
import { EmbedBuilder } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { buildBotInviteUrl, env } from '../../env.js';
import {
helpCommandData,
infoCommandData,
inviteCommandData,
supportCommandData
} from './command-definitions.js';
import { HELP_MODULES } from './help-catalog.js';
const NEXUMI_COLOR = 0x6366f1;
const PACKAGE_VERSION = '0.1.0';
function formatUptime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return `${h}h ${m}m ${s}s`;
}
function webuiBase(): string {
return env.WEBUI_URL ?? env.PUBLIC_BASE_URL;
}
const helpCommand: SlashCommand = {
data: helpCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const filter = interaction.options.getString('module')?.trim().toLowerCase() ?? null;
const modules = filter
? HELP_MODULES.filter((module) => module.id === filter)
: HELP_MODULES;
if (modules.length === 0) {
await interaction.reply({
content: tf(locale, 'core.help.unknownModule', {
modules: HELP_MODULES.map((module) => module.id).join(', ')
}),
ephemeral: true
});
return;
}
const lines = modules.map((module) =>
tf(locale, 'core.help.moduleLine', {
module: t(locale, `core.module.${module.id}`),
commands: module.commands.map((name) => `\`/${name}\``).join(', ')
})
);
// Discord embed description limit is 4096; split across fields if needed.
const embed = new EmbedBuilder()
.setTitle(t(locale, 'core.help.title'))
.setDescription(t(locale, 'core.help.subtitle'))
.setColor(NEXUMI_COLOR)
.setFooter({
text: tf(locale, 'core.help.footer', { webui: webuiBase() })
});
const chunkSize = 8;
for (let i = 0; i < lines.length; i += chunkSize) {
const chunk = lines.slice(i, i + chunkSize);
embed.addFields({
name: '\u200b',
value: chunk.join('\n')
});
}
await interaction.reply({ embeds: [embed], ephemeral: true });
}
};
const infoCommand: SlashCommand = {
data: infoCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const shardId = context.client.shard?.ids[0] ?? 0;
const inviteUrl = buildBotInviteUrl();
const base = webuiBase();
const linkLines = [
`[${t(locale, 'core.info.link.dashboard')}](${base})`,
`[${t(locale, 'core.info.link.invite')}](${inviteUrl})`,
`[${t(locale, 'core.info.link.status')}](${base.replace(/\/$/, '')}/status)`
];
if (env.SUPPORT_SERVER_URL) {
linkLines.push(`[${t(locale, 'core.info.link.support')}](${env.SUPPORT_SERVER_URL})`);
}
const embed = new EmbedBuilder()
.setTitle(t(locale, 'core.info.title'))
.setColor(NEXUMI_COLOR)
.addFields(
{ name: t(locale, 'core.info.version'), value: PACKAGE_VERSION, inline: true },
{
name: t(locale, 'core.info.uptime'),
value: formatUptime(process.uptime()),
inline: true
},
{ name: t(locale, 'core.info.shard'), value: String(shardId), inline: true },
{
name: t(locale, 'core.info.guilds'),
value: String(context.client.guilds.cache.size),
inline: true
},
{
name: t(locale, 'core.info.latency'),
value: `${context.client.ws.ping} ms`,
inline: true
},
{ name: t(locale, 'core.info.links'), value: linkLines.join('\n') }
)
.setFooter({ text: 'Nexumi' });
await interaction.reply({ embeds: [embed], ephemeral: true });
}
};
const inviteCommand: SlashCommand = {
data: inviteCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const inviteUrl = buildBotInviteUrl();
const embed = new EmbedBuilder()
.setTitle(t(locale, 'core.invite.title'))
.setDescription(`${t(locale, 'core.invite.body')}\n${inviteUrl}`)
.setColor(NEXUMI_COLOR);
await interaction.reply({ embeds: [embed], ephemeral: true });
}
};
const supportCommand: SlashCommand = {
data: supportCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!env.SUPPORT_SERVER_URL) {
await interaction.reply({
content: t(locale, 'core.support.missing'),
ephemeral: true
});
return;
}
const embed = new EmbedBuilder()
.setTitle(t(locale, 'core.support.title'))
.setDescription(`${t(locale, 'core.support.body')}\n${env.SUPPORT_SERVER_URL}`)
.setColor(NEXUMI_COLOR);
await interaction.reply({ embeds: [embed], ephemeral: true });
}
};
export const coreCommands: SlashCommand[] = [
helpCommand,
infoCommand,
inviteCommand,
supportCommand
];

View File

@@ -0,0 +1,93 @@
/** Module id → top-level slash command names for `/help`. */
export const HELP_MODULES: ReadonlyArray<{ id: string; commands: readonly string[] }> = [
{ id: 'core', commands: ['help', 'info', 'invite', 'support'] },
{
id: 'moderation',
commands: [
'ban',
'unban',
'kick',
'timeout',
'untimeout',
'warn',
'purge',
'slowmode',
'lock',
'unlock',
'nick',
'case',
'modnote'
]
},
{ id: 'automod', commands: ['automod'] },
{ id: 'welcome', commands: ['welcome'] },
{ id: 'verification', commands: ['verify'] },
{ id: 'leveling', commands: ['rank', 'leaderboard', 'xp'] },
{
id: 'economy',
commands: [
'balance',
'daily',
'weekly',
'work',
'pay',
'gamble',
'slots',
'blackjack',
'coinflip',
'shop',
'inventory',
'eco'
]
},
{
id: 'utility',
commands: [
'userinfo',
'serverinfo',
'roleinfo',
'channelinfo',
'avatar',
'banner',
'poll',
'remindme',
'reminders',
'afk',
'emoji',
'sticker',
'timestamp',
'snipe',
'editsnipe',
'embed'
]
},
{
id: 'fun',
commands: [
'8ball',
'dice',
'flip',
'rps',
'choose',
'trivia',
'tictactoe',
'connect4',
'hangman',
'meme',
'cat',
'dog'
]
},
{ id: 'giveaways', commands: ['giveaway'] },
{ id: 'tickets', commands: ['ticket'] },
{ id: 'selfroles', commands: ['selfroles'] },
{ id: 'tags', commands: ['tag'] },
{ id: 'starboard', commands: ['starboard'] },
{ id: 'suggestions', commands: ['suggest', 'suggestion'] },
{ id: 'birthdays', commands: ['birthday'] },
{ id: 'tempvoice', commands: ['voice'] },
{ id: 'stats', commands: ['stats', 'invites'] },
{ id: 'feeds', commands: ['feeds'] },
{ id: 'scheduler', commands: ['schedule', 'announce'] },
{ id: 'guildbackup', commands: ['backup'] }
];

View File

@@ -0,0 +1,2 @@
export { coreCommands } from './commands.js';
export { HELP_MODULES } from './help-catalog.js';

View File

@@ -1,9 +1,87 @@
import { ActivityType } from 'discord.js'; import { ActivityType, Status } from 'discord.js';
import { PRESENCE_REDIS_KEY, BotPresenceConfigSchema, type BotPresenceConfig } from '@nexumi/shared'; import {
BOT_STATUS_REDIS_KEY,
PRESENCE_REDIS_KEY,
BotPresenceConfigSchema,
BotStatusPayloadSchema,
type BotPresenceConfig
} from '@nexumi/shared';
import { redis } from './redis.js'; import { redis } from './redis.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import type { BotContext } from './types.js'; import type { BotContext } from './types.js';
const BOT_STATUS_SHARDS_HASH = 'bot:status:shards';
const BOT_STATUS_TTL_SECONDS = 120;
function shardStatusLabel(wsStatus: number): string {
switch (wsStatus) {
case Status.Ready:
return 'ready';
case Status.Connecting:
return 'connecting';
case Status.Reconnecting:
return 'reconnecting';
case Status.Idle:
return 'idle';
case Status.Nearly:
return 'nearly';
case Status.Disconnected:
return 'disconnected';
case Status.WaitingForGuilds:
return 'waiting_for_guilds';
case Status.Identifying:
return 'identifying';
case Status.Resuming:
return 'resuming';
default:
return 'unknown';
}
}
export async function publishBotStatus(context: BotContext): Promise<void> {
const shardId = context.client.shard?.ids[0] ?? 0;
const shardEntry = {
id: shardId,
status: shardStatusLabel(context.client.ws.status),
guildCount: context.client.guilds.cache.size,
ping: context.client.ws.ping
};
await redis.hset(BOT_STATUS_SHARDS_HASH, String(shardId), JSON.stringify(shardEntry));
await redis.expire(BOT_STATUS_SHARDS_HASH, BOT_STATUS_TTL_SECONDS);
const all = await redis.hgetall(BOT_STATUS_SHARDS_HASH);
const shards = Object.values(all)
.map((raw) => {
try {
return JSON.parse(raw) as {
id: number;
status: string;
guildCount: number;
ping: number;
};
} catch {
return null;
}
})
.filter((entry): entry is NonNullable<typeof entry> => entry !== null)
.sort((a, b) => a.id - b.id);
if (shards.length === 0) {
return;
}
const payload = BotStatusPayloadSchema.parse({
shards,
apiLatencyMs: Math.round(shards.reduce((sum, shard) => sum + shard.ping, 0) / shards.length),
guildCount: shards.reduce((sum, shard) => sum + shard.guildCount, 0),
uptimeSeconds: Math.floor(process.uptime()),
updatedAt: new Date().toISOString()
});
await redis.set(BOT_STATUS_REDIS_KEY, JSON.stringify(payload), 'EX', BOT_STATUS_TTL_SECONDS);
}
const ACTIVITY_TYPE_MAP: Record<BotPresenceConfig['activityType'], ActivityType> = { const ACTIVITY_TYPE_MAP: Record<BotPresenceConfig['activityType'], ActivityType> = {
Playing: ActivityType.Playing, Playing: ActivityType.Playing,
Listening: ActivityType.Listening, Listening: ActivityType.Listening,
@@ -75,4 +153,9 @@ export async function runPresenceRefreshJob(context: BotContext): Promise<void>
} catch (error) { } catch (error) {
logger.warn({ error }, 'Failed to apply bot presence'); logger.warn({ error }, 'Failed to apply bot presence');
} }
try {
await publishBotStatus(context);
} catch (error) {
logger.warn({ error }, 'Failed to publish bot status');
}
} }

View File

@@ -0,0 +1,37 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Nexumi logo">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#181C2B"/>
<stop offset="1" stop-color="#0C0E15"/>
</linearGradient>
<linearGradient id="mark" x1="0" y1="1" x2="1" y2="0">
<stop offset="0" stop-color="#6366F1"/>
<stop offset="1" stop-color="#8B5CF6"/>
</linearGradient>
</defs>
<!-- App-Icon-Grundflaeche -->
<rect width="512" height="512" rx="116" fill="url(#bg)"/>
<rect x="1.5" y="1.5" width="509" height="509" rx="114.5" fill="none" stroke="#FFFFFF" stroke-opacity="0.06" stroke-width="3"/>
<!-- Glow: gestaffelte Deckkraft statt Filter, rendersicher -->
<g fill="none" stroke="url(#mark)" stroke-linecap="round" stroke-linejoin="round">
<path d="M170 362 V150 L342 362 V150" stroke-width="78" stroke-opacity="0.10"/>
<path d="M170 362 V150 L342 362 V150" stroke-width="52" stroke-opacity="0.16"/>
</g>
<!-- Kanten des N -->
<path d="M170 362 V150 L342 362 V150" fill="none" stroke="url(#mark)"
stroke-width="30" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Knoten -->
<g fill="url(#mark)">
<circle cx="170" cy="150" r="34"/>
<circle cx="170" cy="362" r="34"/>
<circle cx="342" cy="362" r="34"/>
<circle cx="342" cy="150" r="34"/>
</g>
<!-- Aktiver Knoten oben rechts -->
<circle cx="342" cy="150" r="14" fill="#C7D2FE"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import { getPublicStats } from '@/lib/public-stats';
export async function GET() {
try {
const stats = await getPublicStats();
return NextResponse.json(stats, {
headers: { 'Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60' }
});
} catch (error) {
console.error('[public/stats]', error);
return NextResponse.json({ error: 'Failed to load stats' }, { status: 500 });
}
}

View File

@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import { getPublicStatus } from '@/lib/public-status';
export async function GET() {
try {
const status = await getPublicStatus();
return NextResponse.json(status, {
headers: { 'Cache-Control': 'public, s-maxage=15, stale-while-revalidate=30' }
});
} catch (error) {
console.error('[public/status]', error);
return NextResponse.json({ error: 'Failed to load status' }, { status: 500 });
}
}

View File

@@ -0,0 +1,46 @@
import { LegalShell } from '@/components/site/legal-shell';
import { env } from '@/lib/env';
import { getLocale, t } from '@/lib/i18n';
export default async function ImpressumPage() {
const locale = await getLocale();
const configured = Boolean(env.LEGAL_OPERATOR_NAME && env.LEGAL_OPERATOR_ADDRESS && env.LEGAL_OPERATOR_EMAIL);
return (
<LegalShell title={t(locale, 'legal.impressum.title')}>
<p className="text-sm text-muted-foreground">{t(locale, 'legal.impressum.intro')}</p>
{configured ? (
<dl className="space-y-3 text-sm">
<div>
<dt className="font-medium">{t(locale, 'legal.impressum.name')}</dt>
<dd className="text-muted-foreground">{env.LEGAL_OPERATOR_NAME}</dd>
</div>
<div>
<dt className="font-medium">{t(locale, 'legal.impressum.address')}</dt>
<dd className="whitespace-pre-wrap text-muted-foreground">{env.LEGAL_OPERATOR_ADDRESS}</dd>
</div>
<div>
<dt className="font-medium">{t(locale, 'legal.impressum.email')}</dt>
<dd className="text-muted-foreground">
<a className="text-primary hover:underline" href={`mailto:${env.LEGAL_OPERATOR_EMAIL}`}>
{env.LEGAL_OPERATOR_EMAIL}
</a>
</dd>
</div>
{env.LEGAL_OPERATOR_PHONE ? (
<div>
<dt className="font-medium">{t(locale, 'legal.impressum.phone')}</dt>
<dd className="text-muted-foreground">{env.LEGAL_OPERATOR_PHONE}</dd>
</div>
) : null}
</dl>
) : (
<p className="rounded-md border border-border bg-muted/40 px-4 py-3 text-sm">
{t(locale, 'legal.impressum.notConfigured')}
</p>
)}
<p className="text-sm text-muted-foreground">{t(locale, 'legal.impressum.p1')}</p>
<p className="text-sm text-muted-foreground">{t(locale, 'legal.impressum.p2')}</p>
</LegalShell>
);
}

View File

@@ -10,8 +10,8 @@ import './globals.css';
const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' }); const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' });
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Nexumi Dashboard', title: 'Nexumi — Discord Bot',
description: 'Manage your Nexumi Discord bot' description: 'Self-hosted Discord bot with dashboard, moderation, and community modules'
}; };
export default async function RootLayout({ children }: { children: ReactNode }) { export default async function RootLayout({ children }: { children: ReactNode }) {

View File

@@ -1,7 +1,111 @@
import { redirect } from 'next/navigation'; import { DASHBOARD_MODULES, type DashboardModuleGroup } from '@nexumi/shared';
import Image from 'next/image';
import Link from 'next/link';
import { SiteShell } from '@/components/site/site-shell';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { buildBotInviteUrl, env } from '@/lib/env';
import { getLocale, t } from '@/lib/i18n';
import { getPublicStats } from '@/lib/public-stats';
import { getSession } from '@/lib/session'; import { getSession } from '@/lib/session';
export default async function RootPage() { const GROUP_ORDER: DashboardModuleGroup[] = [
const session = await getSession(); 'moderation',
redirect(session ? '/dashboard' : '/login'); 'engagement',
'community',
'utility',
'integrations'
];
export default async function LandingPage() {
const [locale, stats, session] = await Promise.all([getLocale(), getPublicStats(), getSession()]);
const inviteUrl = buildBotInviteUrl();
const isLoggedIn = Boolean(session);
return (
<SiteShell>
<section className="border-b border-border">
<div className="mx-auto flex w-full max-w-[1200px] flex-col gap-8 px-6 py-16 sm:py-20">
<div className="flex items-center gap-3">
<Image src="/nexumi-logo.svg" alt="" width={48} height={48} priority />
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">Nexumi</h1>
</div>
<p className="max-w-2xl text-lg text-muted-foreground">{t(locale, 'landing.hero.subtitle')}</p>
<div className="flex flex-wrap gap-3">
<Button asChild size="lg">
<a href={inviteUrl} target="_blank" rel="noreferrer">
{t(locale, 'landing.hero.invite')}
</a>
</Button>
<Button asChild size="lg" variant="secondary">
<Link href={isLoggedIn ? '/dashboard' : '/login'}>
{isLoggedIn ? t(locale, 'landing.hero.dashboard') : t(locale, 'landing.hero.login')}
</Link>
</Button>
{env.SUPPORT_SERVER_URL ? (
<Button asChild size="lg" variant="outline">
<a href={env.SUPPORT_SERVER_URL} target="_blank" rel="noreferrer">
{t(locale, 'landing.hero.support')}
</a>
</Button>
) : null}
</div>
</div>
</section>
<section className="border-b border-border">
<div className="mx-auto grid w-full max-w-[1200px] gap-4 px-6 py-12 sm:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t(locale, 'landing.stats.guilds')}
</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-semibold">{stats.guildCount.toLocaleString(locale)}</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t(locale, 'landing.stats.users')}
</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-semibold">
{stats.approximateUserCount.toLocaleString(locale)}
</CardContent>
</Card>
</div>
</section>
<section>
<div className="mx-auto w-full max-w-[1200px] space-y-8 px-6 py-12">
<div>
<h2 className="text-2xl font-semibold">{t(locale, 'landing.features.title')}</h2>
<p className="mt-2 text-muted-foreground">{t(locale, 'landing.features.subtitle')}</p>
</div>
{GROUP_ORDER.map((group) => {
const modules = DASHBOARD_MODULES.filter((module) => module.group === group);
return (
<div key={group} className="space-y-3">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{t(locale, `moduleGroups.${group}`)}
</h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{modules.map((module) => (
<Card key={module.id}>
<CardHeader className="pb-2">
<CardTitle className="text-base">{t(locale, `modules.${module.id}.label`)}</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{t(locale, `modules.${module.id}.description`)}
</CardContent>
</Card>
))}
</div>
</div>
);
})}
</div>
</section>
</SiteShell>
);
} }

View File

@@ -0,0 +1,17 @@
import { LegalShell } from '@/components/site/legal-shell';
import { getLocale, t } from '@/lib/i18n';
export default async function PrivacyPage() {
const locale = await getLocale();
return (
<LegalShell title={t(locale, 'legal.privacy.title')}>
<p className="text-sm text-amber-600 dark:text-amber-400">{t(locale, 'legal.scaffoldNotice')}</p>
{['p1', 'p2', 'p3', 'p4', 'p5'].map((key) => (
<p key={key} className="text-sm text-muted-foreground">
{t(locale, `legal.privacy.${key}`)}
</p>
))}
</LegalShell>
);
}

View File

@@ -0,0 +1,120 @@
import { SiteShell } from '@/components/site/site-shell';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { getLocale, t } from '@/lib/i18n';
import { getPublicStatus } from '@/lib/public-status';
function statusBadgeVariant(overall: string): 'default' | 'secondary' | 'destructive' | 'outline' {
if (overall === 'operational') {
return 'default';
}
if (overall === 'maintenance') {
return 'secondary';
}
return 'destructive';
}
export default async function StatusPage() {
const [locale, status] = await Promise.all([getLocale(), getPublicStatus()]);
return (
<SiteShell>
<div className="mx-auto w-full max-w-[1200px] space-y-8 px-6 py-12">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-3xl font-semibold">{t(locale, 'status.title')}</h1>
<Badge variant={statusBadgeVariant(status.overall)}>
{t(locale, `status.overall.${status.overall}`)}
</Badge>
</div>
<p className="text-muted-foreground">{t(locale, 'status.subtitle')}</p>
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">{t(locale, 'status.latency')}</CardTitle>
</CardHeader>
<CardContent className="text-2xl font-semibold">
{status.apiLatencyMs === null ? '—' : `${status.apiLatencyMs} ms`}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">{t(locale, 'status.guilds')}</CardTitle>
</CardHeader>
<CardContent className="text-2xl font-semibold">{status.guildCount}</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">{t(locale, 'status.uptime')}</CardTitle>
</CardHeader>
<CardContent className="text-2xl font-semibold">
{status.uptimeSeconds === null
? '—'
: `${Math.floor(status.uptimeSeconds / 3600)}h ${Math.floor((status.uptimeSeconds % 3600) / 60)}m`}
</CardContent>
</Card>
</div>
<section className="space-y-3">
<h2 className="text-xl font-semibold">{t(locale, 'status.shardsTitle')}</h2>
{status.shards.length === 0 ? (
<Card>
<CardContent className="py-6 text-sm text-muted-foreground">
{t(locale, 'status.shardsEmpty')}
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{status.shards.map((shard) => (
<Card key={shard.id}>
<CardHeader className="pb-2">
<CardTitle className="text-base">
{t(locale, 'status.shardLabel', { id: shard.id })}
</CardTitle>
</CardHeader>
<CardContent className="space-y-1 text-sm text-muted-foreground">
<p>
{t(locale, 'status.shardStatus')}: {shard.status}
</p>
<p>
{t(locale, 'status.guilds')}: {shard.guildCount}
</p>
<p>
{t(locale, 'status.latency')}: {shard.ping} ms
</p>
</CardContent>
</Card>
))}
</div>
)}
</section>
<section className="space-y-3">
<h2 className="text-xl font-semibold">{t(locale, 'status.updatesTitle')}</h2>
<Card>
<CardContent className="divide-y divide-border p-0">
{status.updates.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">{t(locale, 'status.updatesEmpty')}</p>
) : (
status.updates.map((entry) => (
<div key={entry.id} className="space-y-1 px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium">
{entry.version} {entry.title}
</p>
{entry.isIncident ? <Badge variant="destructive">{t(locale, 'status.incident')}</Badge> : null}
</div>
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{entry.body}</p>
<p className="text-xs text-muted-foreground">
{new Date(entry.publishedAt).toLocaleString(locale)}
</p>
</div>
))
)}
</CardContent>
</Card>
</section>
</div>
</SiteShell>
);
}

View File

@@ -0,0 +1,17 @@
import { LegalShell } from '@/components/site/legal-shell';
import { getLocale, t } from '@/lib/i18n';
export default async function TermsPage() {
const locale = await getLocale();
return (
<LegalShell title={t(locale, 'legal.terms.title')}>
<p className="text-sm text-amber-600 dark:text-amber-400">{t(locale, 'legal.scaffoldNotice')}</p>
{['p1', 'p2', 'p3', 'p4'].map((key) => (
<p key={key} className="text-sm text-muted-foreground">
{t(locale, `legal.terms.${key}`)}
</p>
))}
</LegalShell>
);
}

View File

@@ -0,0 +1,39 @@
import Link from 'next/link';
import type { ReactNode } from 'react';
import { SiteShell } from '@/components/site/site-shell';
import { getLocale, t } from '@/lib/i18n';
export async function LegalShell({
title,
children
}: {
title: string;
children: ReactNode;
}) {
const locale = await getLocale();
return (
<SiteShell>
<div className="mx-auto grid w-full max-w-[1200px] gap-8 px-6 py-12 lg:grid-cols-[220px_1fr]">
<aside className="space-y-2 text-sm">
<p className="px-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t(locale, 'legal.navTitle')}
</p>
<Link href="/impressum" className="block rounded-md px-2 py-1.5 hover:bg-accent">
{t(locale, 'legal.impressum.title')}
</Link>
<Link href="/privacy" className="block rounded-md px-2 py-1.5 hover:bg-accent">
{t(locale, 'legal.privacy.title')}
</Link>
<Link href="/terms" className="block rounded-md px-2 py-1.5 hover:bg-accent">
{t(locale, 'legal.terms.title')}
</Link>
</aside>
<article className="space-y-6">
<h1 className="text-3xl font-semibold">{title}</h1>
{children}
</article>
</div>
</SiteShell>
);
}

View File

@@ -0,0 +1,31 @@
import Link from 'next/link';
import { getLocale, t } from '@/lib/i18n';
export async function SiteFooter() {
const locale = await getLocale();
return (
<footer className="border-t border-border">
<div className="mx-auto flex w-full max-w-[1200px] flex-col gap-4 px-6 py-8 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<p>© {new Date().getFullYear()} Nexumi</p>
<nav className="flex flex-wrap gap-4">
<Link href="/status" className="hover:text-foreground">
{t(locale, 'site.footer.status')}
</Link>
<Link href="/impressum" className="hover:text-foreground">
{t(locale, 'site.footer.impressum')}
</Link>
<Link href="/privacy" className="hover:text-foreground">
{t(locale, 'site.footer.privacy')}
</Link>
<Link href="/terms" className="hover:text-foreground">
{t(locale, 'site.footer.terms')}
</Link>
<Link href="/dashboard" className="hover:text-foreground">
{t(locale, 'site.footer.dashboard')}
</Link>
</nav>
</div>
</footer>
);
}

View File

@@ -0,0 +1,62 @@
'use client';
import { Moon, Sun } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { useTheme } from 'next-themes';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
export function SiteHeader({
inviteUrl,
supportUrl,
isLoggedIn
}: {
inviteUrl: string;
supportUrl?: string;
isLoggedIn: boolean;
}) {
const t = useTranslations();
const { theme, setTheme } = useTheme();
return (
<header className="border-b border-border">
<div className="mx-auto flex h-14 w-full max-w-[1200px] items-center justify-between px-6">
<Link href="/" className="flex items-center gap-2 font-semibold">
<Image src="/nexumi-logo.svg" alt="Nexumi" width={28} height={28} priority />
Nexumi
</Link>
<nav className="flex items-center gap-2">
<Button asChild variant="ghost" size="sm">
<Link href="/status">{t('site.nav.status')}</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<a href={inviteUrl} target="_blank" rel="noreferrer">
{t('site.nav.invite')}
</a>
</Button>
{supportUrl ? (
<Button asChild variant="ghost" size="sm">
<a href={supportUrl} target="_blank" rel="noreferrer">
{t('site.nav.support')}
</a>
</Button>
) : null}
<Button asChild size="sm">
<Link href={isLoggedIn ? '/dashboard' : '/login'}>
{isLoggedIn ? t('site.nav.dashboard') : t('site.nav.login')}
</Link>
</Button>
<Button
variant="ghost"
size="sm"
aria-label={t('userMenu.theme')}
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
>
{theme === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />}
</Button>
</nav>
</div>
</header>
);
}

View File

@@ -0,0 +1,21 @@
import type { ReactNode } from 'react';
import { SiteFooter } from '@/components/site/site-footer';
import { SiteHeader } from '@/components/site/site-header';
import { buildBotInviteUrl, env } from '@/lib/env';
import { getSession } from '@/lib/session';
export async function SiteShell({ children }: { children: ReactNode }) {
const session = await getSession();
return (
<div className="flex min-h-screen flex-col bg-background text-foreground">
<SiteHeader
inviteUrl={buildBotInviteUrl()}
supportUrl={env.SUPPORT_SERVER_URL}
isLoggedIn={Boolean(session)}
/>
<main className="flex-1">{children}</main>
<SiteFooter />
</div>
);
}

View File

@@ -3,28 +3,20 @@ import { z } from 'zod';
config(); config();
const emptyToUndefined = (value: unknown) =>
value === '' || value === undefined || value === null ? undefined : value;
const EnvSchema = z.object({ const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
DATABASE_URL: z.string().url(), DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(), REDIS_URL: z.string().url(),
BOT_CLIENT_ID: z.string().min(1), BOT_CLIENT_ID: z.string().min(1),
BOT_CLIENT_SECRET: z.string().min(1), BOT_CLIENT_SECRET: z.string().min(1),
/**
* Discord bot token. The WebUI does not run a discord.js Client/gateway
* connection, but uses this token for narrow, on-demand Discord REST calls
* (e.g. building a guild backup snapshot) and to enqueue BullMQ jobs that
* the bot process picks up for actions that require a live Client
* (posting messages, restoring a backup).
*/
BOT_TOKEN: z.string().min(1), BOT_TOKEN: z.string().min(1),
WEBUI_URL: z.string().url().default('http://localhost:3000'), WEBUI_URL: z.string().url().default('http://localhost:3000'),
SESSION_SECRET: z.string().min(32, 'SESSION_SECRET must be at least 32 characters long'), SESSION_SECRET: z.string().min(32, 'SESSION_SECRET must be at least 32 characters long'),
SENTRY_DSN: z.string().optional(), SENTRY_DSN: z.preprocess(emptyToUndefined, z.string().optional()),
DEFAULT_LOCALE: z.enum(['de', 'en']).default('de'), DEFAULT_LOCALE: z.enum(['de', 'en']).default('de'),
/**
* Comma-separated Discord user IDs that are treated as global bot owners
* for the (future) owner panel. Optional for now.
*/
OWNER_USER_IDS: z.preprocess( OWNER_USER_IDS: z.preprocess(
(value) => (typeof value === 'string' && value.trim() === '' ? undefined : value), (value) => (typeof value === 'string' && value.trim() === '' ? undefined : value),
z z
@@ -38,8 +30,21 @@ const EnvSchema = z.object({
.filter((id) => id.length > 0) .filter((id) => id.length > 0)
: [] : []
) )
) ),
SUPPORT_SERVER_URL: z.preprocess(emptyToUndefined, z.string().url().optional()),
LEGAL_OPERATOR_NAME: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
LEGAL_OPERATOR_ADDRESS: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
LEGAL_OPERATOR_EMAIL: z.preprocess(emptyToUndefined, z.string().email().optional()),
LEGAL_OPERATOR_PHONE: z.preprocess(emptyToUndefined, z.string().min(1).optional())
}); });
export const env = EnvSchema.parse(process.env); export const env = EnvSchema.parse(process.env);
export type Env = typeof env; export type Env = typeof env;
export function buildBotInviteUrl(clientId = env.BOT_CLIENT_ID): string {
const url = new URL('https://discord.com/api/oauth2/authorize');
url.searchParams.set('client_id', clientId);
url.searchParams.set('permissions', '8');
url.searchParams.set('scope', 'bot applications.commands');
return url.toString();
}

View File

@@ -0,0 +1,24 @@
import { prisma } from './prisma';
export async function getPublicStats() {
const [guildCount, memberLevels, memberEconomies, users] = await Promise.all([
prisma.guild.count(),
prisma.memberLevel.findMany({ select: { userId: true }, distinct: ['userId'] }),
prisma.memberEconomy.findMany({ select: { userId: true }, distinct: ['userId'] }),
prisma.user.count()
]);
const unique = new Set<string>();
for (const row of memberLevels) {
unique.add(row.userId);
}
for (const row of memberEconomies) {
unique.add(row.userId);
}
return {
guildCount,
approximateUserCount: Math.max(unique.size, users),
updatedAt: new Date().toISOString()
};
}

View File

@@ -0,0 +1,56 @@
import { BOT_STATUS_REDIS_KEY, BotStatusPayloadSchema, PRESENCE_REDIS_KEY } from '@nexumi/shared';
import { prisma } from './prisma';
import { redis } from './redis';
export async function getPublicStatus() {
const [rawStatus, rawPresence, updates] = await Promise.all([
redis.get(BOT_STATUS_REDIS_KEY),
redis.get(PRESENCE_REDIS_KEY),
prisma.changelogEntry.findMany({
orderBy: { publishedAt: 'desc' },
take: 10
})
]);
let botStatus = null;
if (rawStatus) {
const parsed = BotStatusPayloadSchema.safeParse(JSON.parse(rawStatus));
botStatus = parsed.success ? parsed.data : null;
}
let maintenanceMode = false;
if (rawPresence) {
try {
const presence = JSON.parse(rawPresence) as { maintenanceMode?: boolean };
maintenanceMode = Boolean(presence.maintenanceMode);
} catch {
maintenanceMode = false;
}
} else {
const row = await prisma.botPresenceConfig.findUnique({ where: { id: 'singleton' } });
maintenanceMode = row?.maintenanceMode ?? false;
}
const degraded = !botStatus;
const overall = maintenanceMode ? 'maintenance' : degraded ? 'degraded' : 'operational';
return {
ok: !maintenanceMode,
overall,
maintenanceMode,
degraded,
shards: botStatus?.shards ?? [],
apiLatencyMs: botStatus?.apiLatencyMs ?? null,
guildCount: botStatus?.guildCount ?? (await prisma.guild.count()),
uptimeSeconds: botStatus?.uptimeSeconds ?? null,
updates: updates.map((entry) => ({
id: entry.id,
version: entry.version,
title: entry.title,
body: entry.body,
publishedAt: entry.publishedAt.toISOString(),
isIncident: entry.version.toLowerCase().startsWith('incident')
})),
updatedAt: botStatus?.updatedAt ?? new Date().toISOString()
};
}

View File

@@ -36,26 +36,86 @@
"integrations": "Integrationen" "integrations": "Integrationen"
}, },
"modules": { "modules": {
"moderation": { "label": "Moderation", "description": "Bans, Verwarnungen, Timeouts und das Fall-System" }, "moderation": {
"automod": { "label": "Auto-Moderation", "description": "Spam-, Raid- und Nuke-Schutz" }, "label": "Moderation",
"logging": { "label": "Logging", "description": "Log-Kanäle für Server-Events" }, "description": "Bans, Verwarnungen, Timeouts und das Fall-System"
"welcome": { "label": "Willkommen & Abschied", "description": "Join- und Leave-Nachrichten, Autoroles" }, },
"verification": { "label": "Verifizierung", "description": "Button- und Captcha-Verifizierung" }, "automod": {
"leveling": { "label": "Leveling & XP", "description": "Text- und Voice-XP, Rollen-Belohnungen" }, "label": "Auto-Moderation",
"economy": { "label": "Economy", "description": "Währung, Shop und Spiele" }, "description": "Spam-, Raid- und Nuke-Schutz"
"fun": { "label": "Fun & Spiele", "description": "Minispiele und Medien-Commands" }, },
"giveaways": { "label": "Giveaways", "description": "Button-basierte Giveaways" }, "logging": {
"tickets": { "label": "Tickets", "description": "Support-Ticket-Panels und Transkripte" }, "label": "Logging",
"selfroles": { "label": "Self Roles", "description": "Reaktions-, Button- und Dropdown-Rollenpanels" }, "description": "Log-Kanäle für Server-Events"
"tags": { "label": "Tags", "description": "Custom Commands und Auto-Responder" }, },
"starboard": { "label": "Starboard", "description": "Beliebte Nachrichten hervorheben" }, "welcome": {
"suggestions": { "label": "Vorschläge", "description": "Community-Vorschläge mit Abstimmung" }, "label": "Willkommen & Abschied",
"birthdays": { "label": "Geburtstage", "description": "Geburtstags-Ankündigungen und -Rollen" }, "description": "Join- und Leave-Nachrichten, Autoroles"
"tempvoice": { "label": "Temp-Voice", "description": "Join-to-Create Voice-Kanäle" }, },
"stats": { "label": "Server-Statistiken", "description": "Automatische Statistik-Kanäle und Invites" }, "verification": {
"feeds": { "label": "Social Feeds", "description": "Twitch-, YouTube-, RSS- und Reddit-Benachrichtigungen" }, "label": "Verifizierung",
"scheduler": { "label": "Scheduler", "description": "Geplante und wiederkehrende Ankündigungen" }, "description": "Button- und Captcha-Verifizierung"
"guildbackup": { "label": "Backups", "description": "Server-Backup und -Wiederherstellung" } },
"leveling": {
"label": "Leveling & XP",
"description": "Text- und Voice-XP, Rollen-Belohnungen"
},
"economy": {
"label": "Economy",
"description": "Währung, Shop und Spiele"
},
"fun": {
"label": "Fun & Spiele",
"description": "Minispiele und Medien-Commands"
},
"giveaways": {
"label": "Giveaways",
"description": "Button-basierte Giveaways"
},
"tickets": {
"label": "Tickets",
"description": "Support-Ticket-Panels und Transkripte"
},
"selfroles": {
"label": "Self Roles",
"description": "Reaktions-, Button- und Dropdown-Rollenpanels"
},
"tags": {
"label": "Tags",
"description": "Custom Commands und Auto-Responder"
},
"starboard": {
"label": "Starboard",
"description": "Beliebte Nachrichten hervorheben"
},
"suggestions": {
"label": "Vorschläge",
"description": "Community-Vorschläge mit Abstimmung"
},
"birthdays": {
"label": "Geburtstage",
"description": "Geburtstags-Ankündigungen und -Rollen"
},
"tempvoice": {
"label": "Temp-Voice",
"description": "Join-to-Create Voice-Kanäle"
},
"stats": {
"label": "Server-Statistiken",
"description": "Automatische Statistik-Kanäle und Invites"
},
"feeds": {
"label": "Social Feeds",
"description": "Twitch-, YouTube-, RSS- und Reddit-Benachrichtigungen"
},
"scheduler": {
"label": "Scheduler",
"description": "Geplante und wiederkehrende Ankündigungen"
},
"guildbackup": {
"label": "Backups",
"description": "Server-Backup und -Wiederherstellung"
}
}, },
"login": { "login": {
"title": "Nexumi", "title": "Nexumi",
@@ -141,7 +201,11 @@
"addEscalation": "Regel hinzufügen", "addEscalation": "Regel hinzufügen",
"warnCount": "Anzahl Verwarnungen", "warnCount": "Anzahl Verwarnungen",
"action": "Aktion", "action": "Aktion",
"actions": { "TIMEOUT": "Timeout", "KICK": "Kick", "BAN": "Ban" }, "actions": {
"TIMEOUT": "Timeout",
"KICK": "Kick",
"BAN": "Ban"
},
"durationMs": "Dauer (ms)", "durationMs": "Dauer (ms)",
"reason": "Grund", "reason": "Grund",
"reasonPlaceholder": "Optionaler Standardgrund", "reasonPlaceholder": "Optionaler Standardgrund",
@@ -197,7 +261,11 @@
"welcomeEnabledLabel": "Willkommensnachricht aktiviert", "welcomeEnabledLabel": "Willkommensnachricht aktiviert",
"welcomeChannelId": "Willkommens-Kanal-ID", "welcomeChannelId": "Willkommens-Kanal-ID",
"welcomeType": "Nachrichtentyp", "welcomeType": "Nachrichtentyp",
"type": { "TEXT": "Text", "EMBED": "Embed", "IMAGE": "Bild-Karte" }, "type": {
"TEXT": "Text",
"EMBED": "Embed",
"IMAGE": "Bild-Karte"
},
"welcomeContent": "Nachrichtentext", "welcomeContent": "Nachrichtentext",
"contentPlaceholder": "Willkommen {user} auf {server}!", "contentPlaceholder": "Willkommen {user} auf {server}!",
"placeholdersHint": "Verfügbare Platzhalter: {user}, {user.name}, {user.tag}, {user.id}, {server}, {memberCount}", "placeholdersHint": "Verfügbare Platzhalter: {user}, {user.name}, {user.tag}, {user.id}, {server}, {memberCount}",
@@ -337,7 +405,11 @@
"channelId": "Kanal-ID", "channelId": "Kanal-ID",
"panelTitle": "Panel-Titel", "panelTitle": "Panel-Titel",
"mode": "Modus", "mode": "Modus",
"modes": { "BUTTONS": "Buttons", "DROPDOWN": "Dropdown", "REACTIONS": "Reaktionen" }, "modes": {
"BUTTONS": "Buttons",
"DROPDOWN": "Dropdown",
"REACTIONS": "Reaktionen"
},
"behavior": "Verhalten", "behavior": "Verhalten",
"behaviors": { "behaviors": {
"NORMAL": "Normal (frei umschaltbar)", "NORMAL": "Normal (frei umschaltbar)",
@@ -361,7 +433,10 @@
"createDescription": "Custom Commands und Auto-Responder, ausgelöst per Name oder Stichwort.", "createDescription": "Custom Commands und Auto-Responder, ausgelöst per Name oder Stichwort.",
"name": "Name", "name": "Name",
"responseType": "Antworttyp", "responseType": "Antworttyp",
"responseTypes": { "TEXT": "Text", "EMBED": "Embed" }, "responseTypes": {
"TEXT": "Text",
"EMBED": "Embed"
},
"triggerWord": "Auslöse-Wort (optional)", "triggerWord": "Auslöse-Wort (optional)",
"content": "Inhalt", "content": "Inhalt",
"allowedRoleIds": "Erlaubte Rollen-IDs", "allowedRoleIds": "Erlaubte Rollen-IDs",
@@ -620,5 +695,88 @@
"forbidden": "Du hast keinen Zugriff auf diesen Server.", "forbidden": "Du hast keinen Zugriff auf diesen Server.",
"notFound": "Nicht gefunden.", "notFound": "Nicht gefunden.",
"generic": "Etwas ist schiefgelaufen. Bitte erneut versuchen." "generic": "Etwas ist schiefgelaufen. Bitte erneut versuchen."
},
"site": {
"nav": {
"status": "Status",
"invite": "Einladen",
"support": "Support",
"login": "Anmelden",
"dashboard": "Dashboard",
"theme": "Design"
},
"footer": {
"status": "Status",
"impressum": "Impressum",
"privacy": "Datenschutz",
"terms": "Nutzungsbedingungen",
"dashboard": "Dashboard"
}
},
"landing": {
"hero": {
"subtitle": "Selbst gehosteter Discord-Bot mit Moderations-, Community- und Integrationsmodulen sowie Web-Dashboard.",
"invite": "Bot einladen",
"login": "Zum Dashboard",
"dashboard": "Dashboard öffnen",
"support": "Support-Server"
},
"stats": {
"guilds": "Server",
"users": "Nutzer (ca.)"
},
"features": {
"title": "Module",
"subtitle": "Funktionen, die Nexumi pro Server bereitstellt."
}
},
"status": {
"title": "Status",
"subtitle": "Verfügbarkeit, Shard-Status und aktuelle Hinweise.",
"overall": {
"operational": "Betriebsbereit",
"maintenance": "Wartung",
"degraded": "Eingeschränkt"
},
"latency": "API-Latenz",
"guilds": "Server",
"uptime": "Bot-Uptime",
"shardsTitle": "Shards",
"shardsEmpty": "Keine Shard-Daten vom Bot (Redis bot:status fehlt oder ist abgelaufen).",
"shardLabel": "Shard {id}",
"shardStatus": "Status",
"updatesTitle": "Updates & Incidents",
"updatesEmpty": "Noch keine Einträge.",
"incident": "Incident"
},
"legal": {
"navTitle": "Rechtliches",
"scaffoldNotice": "Gerüsttext — bitte vor Produktion rechtlich prüfen und anpassen.",
"impressum": {
"title": "Impressum",
"intro": "Angaben gemäß § 5 DDG.",
"name": "Anbieter",
"address": "Anschrift",
"email": "E-Mail",
"phone": "Telefon",
"notConfigured": "Betreiberdaten fehlen. Bitte LEGAL_OPERATOR_NAME, LEGAL_OPERATOR_ADDRESS und LEGAL_OPERATOR_EMAIL in der .env setzen.",
"p1": "Verantwortlich für den Inhalt dieser Website und den Betrieb von Nexumi ist der oben genannte Anbieter.",
"p2": "Für Inhalte externer Links übernehmen wir keine Haftung. Für den Inhalt der verlinkten Seiten sind ausschließlich deren Betreiber verantwortlich."
},
"privacy": {
"title": "Datenschutzerklärung",
"p1": "Nexumi verarbeitet Discord-Daten, soweit dies für den Betrieb des Bots und des Dashboards erforderlich ist (z. B. Server-IDs, Nutzer-IDs, Moderationsfälle, Modul-Einstellungen).",
"p2": "Die Anmeldung am Dashboard erfolgt über Discord OAuth2 (Scopes identify und guilds). Es werden Sitzungsdaten in Redis gespeichert.",
"p3": "Daten werden in der selbst gehosteten PostgreSQL-Datenbank gespeichert. Aufbewahrungsdauern sind über Modul-Einstellungen bzw. Löschanfragen steuerbar.",
"p4": "Es findet keine Weitergabe an Dritte statt, außer soweit für den Betrieb technisch erforderlich (z. B. Discord API, optional Sentry).",
"p5": "Betroffene können Auskunft, Berichtigung und Löschung verlangen. Kontakt über die im Impressum genannte Adresse oder den Bot-Command /gdpr sofern verfügbar."
},
"terms": {
"title": "Nutzungsbedingungen",
"p1": "Nexumi wird als selbst gehosteter Discord-Bot bereitgestellt. Die Nutzung erfolgt auf eigene Verantwortung.",
"p2": "Missbrauch, Spam oder Verstöße gegen die Discord Terms of Service können zum Ausschluss (Blacklist) führen.",
"p3": "Wir übernehmen keine Gewähr für unterbrechungsfreien Betrieb. Wartungsfenster werden nach Möglichkeit angekündigt.",
"p4": "Es gilt das Recht der Bundesrepublik Deutschland, soweit zwingende Verbraucherschutzvorschriften nichts anderes vorsehen."
}
} }
} }

View File

@@ -36,26 +36,86 @@
"integrations": "Integrations" "integrations": "Integrations"
}, },
"modules": { "modules": {
"moderation": { "label": "Moderation", "description": "Bans, warns, timeouts and the case system" }, "moderation": {
"automod": { "label": "Auto-Moderation", "description": "Spam, raid and nuke protection" }, "label": "Moderation",
"logging": { "label": "Logging", "description": "Server event log channels" }, "description": "Bans, warns, timeouts and the case system"
"welcome": { "label": "Welcome & Leave", "description": "Join and leave messages, autoroles" }, },
"verification": { "label": "Verification", "description": "Button and captcha verification" }, "automod": {
"leveling": { "label": "Leveling & XP", "description": "Text and voice XP, level rewards" }, "label": "Auto-Moderation",
"economy": { "label": "Economy", "description": "Currency, shop and games" }, "description": "Spam, raid and nuke protection"
"fun": { "label": "Fun & Games", "description": "Mini games and media commands" }, },
"giveaways": { "label": "Giveaways", "description": "Button-based giveaways" }, "logging": {
"tickets": { "label": "Tickets", "description": "Support ticket panels and transcripts" }, "label": "Logging",
"selfroles": { "label": "Self Roles", "description": "Reaction, button and dropdown role panels" }, "description": "Server event log channels"
"tags": { "label": "Tags", "description": "Custom commands and auto-responders" }, },
"starboard": { "label": "Starboard", "description": "Highlight popular messages" }, "welcome": {
"suggestions": { "label": "Suggestions", "description": "Community suggestion voting" }, "label": "Welcome & Leave",
"birthdays": { "label": "Birthdays", "description": "Birthday announcements and roles" }, "description": "Join and leave messages, autoroles"
"tempvoice": { "label": "Temp Voice", "description": "Join-to-create voice channels" }, },
"stats": { "label": "Server Stats", "description": "Auto-updating stat channels and invites" }, "verification": {
"feeds": { "label": "Social Feeds", "description": "Twitch, YouTube, RSS and Reddit notifications" }, "label": "Verification",
"scheduler": { "label": "Scheduler", "description": "Scheduled and recurring announcements" }, "description": "Button and captcha verification"
"guildbackup": { "label": "Backups", "description": "Server backup and restore" } },
"leveling": {
"label": "Leveling & XP",
"description": "Text and voice XP, level rewards"
},
"economy": {
"label": "Economy",
"description": "Currency, shop and games"
},
"fun": {
"label": "Fun & Games",
"description": "Mini games and media commands"
},
"giveaways": {
"label": "Giveaways",
"description": "Button-based giveaways"
},
"tickets": {
"label": "Tickets",
"description": "Support ticket panels and transcripts"
},
"selfroles": {
"label": "Self Roles",
"description": "Reaction, button and dropdown role panels"
},
"tags": {
"label": "Tags",
"description": "Custom commands and auto-responders"
},
"starboard": {
"label": "Starboard",
"description": "Highlight popular messages"
},
"suggestions": {
"label": "Suggestions",
"description": "Community suggestion voting"
},
"birthdays": {
"label": "Birthdays",
"description": "Birthday announcements and roles"
},
"tempvoice": {
"label": "Temp Voice",
"description": "Join-to-create voice channels"
},
"stats": {
"label": "Server Stats",
"description": "Auto-updating stat channels and invites"
},
"feeds": {
"label": "Social Feeds",
"description": "Twitch, YouTube, RSS and Reddit notifications"
},
"scheduler": {
"label": "Scheduler",
"description": "Scheduled and recurring announcements"
},
"guildbackup": {
"label": "Backups",
"description": "Server backup and restore"
}
}, },
"login": { "login": {
"title": "Nexumi", "title": "Nexumi",
@@ -141,7 +201,11 @@
"addEscalation": "Add rule", "addEscalation": "Add rule",
"warnCount": "Warning count", "warnCount": "Warning count",
"action": "Action", "action": "Action",
"actions": { "TIMEOUT": "Timeout", "KICK": "Kick", "BAN": "Ban" }, "actions": {
"TIMEOUT": "Timeout",
"KICK": "Kick",
"BAN": "Ban"
},
"durationMs": "Duration (ms)", "durationMs": "Duration (ms)",
"reason": "Reason", "reason": "Reason",
"reasonPlaceholder": "Optional default reason", "reasonPlaceholder": "Optional default reason",
@@ -197,7 +261,11 @@
"welcomeEnabledLabel": "Welcome message enabled", "welcomeEnabledLabel": "Welcome message enabled",
"welcomeChannelId": "Welcome channel ID", "welcomeChannelId": "Welcome channel ID",
"welcomeType": "Message type", "welcomeType": "Message type",
"type": { "TEXT": "Text", "EMBED": "Embed", "IMAGE": "Image card" }, "type": {
"TEXT": "Text",
"EMBED": "Embed",
"IMAGE": "Image card"
},
"welcomeContent": "Message content", "welcomeContent": "Message content",
"contentPlaceholder": "Welcome {user} to {server}!", "contentPlaceholder": "Welcome {user} to {server}!",
"placeholdersHint": "Available placeholders: {user}, {user.name}, {user.tag}, {user.id}, {server}, {memberCount}", "placeholdersHint": "Available placeholders: {user}, {user.name}, {user.tag}, {user.id}, {server}, {memberCount}",
@@ -337,7 +405,11 @@
"channelId": "Channel ID", "channelId": "Channel ID",
"panelTitle": "Panel title", "panelTitle": "Panel title",
"mode": "Mode", "mode": "Mode",
"modes": { "BUTTONS": "Buttons", "DROPDOWN": "Dropdown", "REACTIONS": "Reactions" }, "modes": {
"BUTTONS": "Buttons",
"DROPDOWN": "Dropdown",
"REACTIONS": "Reactions"
},
"behavior": "Behavior", "behavior": "Behavior",
"behaviors": { "behaviors": {
"NORMAL": "Normal (toggle freely)", "NORMAL": "Normal (toggle freely)",
@@ -361,7 +433,10 @@
"createDescription": "Custom commands and auto-responders triggered by name or keyword.", "createDescription": "Custom commands and auto-responders triggered by name or keyword.",
"name": "Name", "name": "Name",
"responseType": "Response type", "responseType": "Response type",
"responseTypes": { "TEXT": "Text", "EMBED": "Embed" }, "responseTypes": {
"TEXT": "Text",
"EMBED": "Embed"
},
"triggerWord": "Trigger word (optional)", "triggerWord": "Trigger word (optional)",
"content": "Content", "content": "Content",
"allowedRoleIds": "Allowed role IDs", "allowedRoleIds": "Allowed role IDs",
@@ -620,5 +695,88 @@
"forbidden": "You do not have access to this server.", "forbidden": "You do not have access to this server.",
"notFound": "Not found.", "notFound": "Not found.",
"generic": "Something went wrong. Please try again." "generic": "Something went wrong. Please try again."
},
"site": {
"nav": {
"status": "Status",
"invite": "Invite",
"support": "Support",
"login": "Log in",
"dashboard": "Dashboard",
"theme": "Theme"
},
"footer": {
"status": "Status",
"impressum": "Legal notice",
"privacy": "Privacy",
"terms": "Terms",
"dashboard": "Dashboard"
}
},
"landing": {
"hero": {
"subtitle": "Self-hosted Discord bot with moderation, community, and integration modules plus a web dashboard.",
"invite": "Invite bot",
"login": "Open dashboard",
"dashboard": "Open dashboard",
"support": "Support server"
},
"stats": {
"guilds": "Servers",
"users": "Users (approx.)"
},
"features": {
"title": "Modules",
"subtitle": "Features Nexumi provides per server."
}
},
"status": {
"title": "Status",
"subtitle": "Availability, shard status, and recent notices.",
"overall": {
"operational": "Operational",
"maintenance": "Maintenance",
"degraded": "Degraded"
},
"latency": "API latency",
"guilds": "Servers",
"uptime": "Bot uptime",
"shardsTitle": "Shards",
"shardsEmpty": "No shard data from the bot (Redis bot:status missing or expired).",
"shardLabel": "Shard {id}",
"shardStatus": "Status",
"updatesTitle": "Updates & incidents",
"updatesEmpty": "No entries yet.",
"incident": "Incident"
},
"legal": {
"navTitle": "Legal",
"scaffoldNotice": "Scaffold text — review and adapt before production use.",
"impressum": {
"title": "Legal notice",
"intro": "Information according to German Telemedia Act (DDG).",
"name": "Operator",
"address": "Address",
"email": "Email",
"phone": "Phone",
"notConfigured": "Operator details missing. Set LEGAL_OPERATOR_NAME, LEGAL_OPERATOR_ADDRESS, and LEGAL_OPERATOR_EMAIL in .env.",
"p1": "The operator named above is responsible for this website and for running Nexumi.",
"p2": "We are not responsible for external links. Operators of linked sites are solely responsible for their content."
},
"privacy": {
"title": "Privacy policy",
"p1": "Nexumi processes Discord data as required to operate the bot and dashboard (e.g. guild IDs, user IDs, moderation cases, module settings).",
"p2": "Dashboard login uses Discord OAuth2 (identify and guilds scopes). Session data is stored in Redis.",
"p3": "Data is stored in the self-hosted PostgreSQL database. Retention can be configured per module or via deletion requests.",
"p4": "Data is not shared with third parties except where technically required (e.g. Discord API, optional Sentry).",
"p5": "Data subjects may request access, correction, and deletion via the contact in the legal notice or bot commands where available."
},
"terms": {
"title": "Terms of service",
"p1": "Nexumi is provided as a self-hosted Discord bot. Use is at your own risk.",
"p2": "Abuse, spam, or violations of Discord Terms of Service may result in blacklisting.",
"p3": "We do not guarantee uninterrupted service. Maintenance windows are announced when possible.",
"p4": "German law applies unless mandatory consumer protection rules require otherwise."
}
} }
} }

View File

@@ -3,23 +3,42 @@
- OAuth/Sessions, Settings-Framework, Layout, Modul-Toggles, Access-Rules - OAuth/Sessions, Settings-Framework, Layout, Modul-Toggles, Access-Rules
- Login manuell bestätigt (`WEBUI_URL=http://10.111.0.65:3000`) - Login manuell bestätigt (`WEBUI_URL=http://10.111.0.65:3000`)
## Phase 7 WebUI Modul-Seiten + Owner-Panel (Status: implementiert, manuelle Tests ausstehend) ## Phase 7 WebUI Modul-Seiten + Owner-Panel (Status: abgeschlossen, manuell bestätigt)
### Abgeschlossen ### Abgeschlossen
- **Batch A:** Moderation, Automod, Logging, Welcome, Verification, Leveling, Economy, Fun + Prisma Owner-Modelle (`20260722190000_phase7_owner`) - Alle Modul-Dashboard-Seiten, Commands-Seite, Overview-Aktivität
- **Batch B:** Tickets, Giveaways, SelfRoles, Tags, Starboard, Suggestions, Birthdays, TempVoice, Stats, Feeds, Scheduler, Backup, Commands-Seite, Overview-Aktivität; BullMQ-Anbindung WebUI↔Bot - Owner-Panel (`/owner`: Übersicht, Guilds, Users, Flags, Presence, Team, Jobs, Changelog, Audit)
- **Batch C (Owner-Panel):** `/owner` Übersicht, Guilds (Leave/Blacklist), User-Blacklist, Feature-Flags, Presence (+ Bot-Job `presence` + Wartungsmodus in `routeCommand`), Team, Jobs/Migrationen, Changelog, Owner-Audit; Auth über `OWNER_USER_IDS` + `OwnerTeamMember` - Prisma `20260722190000_phase7_owner`, Shared Zod (`dashboard.ts`, `owner.ts`)
- Shared: `dashboard.ts`, `owner.ts` + Tests - BullMQ WebUI↔Bot, Presence-/Wartungsmodus-Anbindung
- Checks: shared 46 Tests, webui typecheck/lint, bot typecheck grün - Manuell bestätigt (User-Freigabe)
## Phase 8 Landing, Status, Rechtsseiten (Status: implementiert, manuelle Tests offen)
### Abgeschlossen (Code)
- Landing `/` (Features, Invite, Support, Live-Stats, Login/Dashboard)
- Status `/status` (Shards/Latenz/Uptime aus Redis `bot:status`, Changelog/Incidents)
- Rechtsseiten-Gerüst `/impressum`, `/privacy`, `/terms` (DE+EN i18n; Impressum aus Env)
- Public APIs `GET /api/public/stats`, `GET /api/public/status`
- Bot schreibt `bot:status` (Presence-Job + Ready); Core-Commands `/help`, `/info`, `/invite`, `/support`
- `docs/verification.md` (Privileged Intents)
- Env: `SUPPORT_SERVER_URL`, `LEGAL_OPERATOR_*` (WebUI), `WEBUI_URL`/`SUPPORT_SERVER_URL` (Bot)
### Manuell testen ### Manuell testen
1. `OWNER_USER_IDS=<deine Discord-User-ID>` in `.env` setzen, Stack neu starten - [ ] Landing unter `WEBUI_URL` (nicht localhost): Stats, Invite, Login
2. Dashboard-Modul-Seiten speichern (Settings/Module/Cases etc.) - [ ] `/status` zeigt Shards nach Bot-Start (sonst „degraded“ bis Redis-Status da ist)
3. Owner-Panel: `/owner` → Presence, Flags, Team, Jobs - [ ] Impressum: mit/ohne `LEGAL_OPERATOR_*` in `.env`
4. Giveaway/Suggestion/Backup-Aktionen aus dem Dashboard (BullMQ) - [ ] Privacy/Terms in DE und EN (Locale-Cookie)
- [ ] Discord: `/help`, `/info`, `/invite`, `/support`
- [ ] Support-Link nur sichtbar, wenn `SUPPORT_SERVER_URL` gesetzt
### Bewusst offen (SPEC, nicht Phase 8)
- Musik- und KI-Modul (nur auf Zuruf)
- Premium-Zahlungsanbindung (manuelle Zuweisung bleibt Owner-Panel)
## Nächster geplanter Schritt ## Nächster geplanter Schritt
- Nach Freigabe Phase 8 (Landing Page, Status-Seite, Rechtsseiten-Gerüst). - Nach manueller Freigabe Phase 8: auf Zuruf Musik/KI oder andere SPEC-Restpunkte.

41
docs/verification.md Normal file
View File

@@ -0,0 +1,41 @@
# Discord Bot Verification Privileged Intents
This document lists why Nexumi requires privileged Gateway intents for Discord's verification application.
Update it when modules gain or lose intent dependencies.
## Server Members Intent (`GuildMembers`)
Required so the bot can receive and act on member join/leave/update events and resolve member lists beyond the cache.
| Module | Why |
|--------|-----|
| Welcome & Abschied | Join/leave messages, autoroles, member-count placeholders |
| Verification | Assign verified/unverified roles on join and after button/captcha success |
| Logging | Member join/leave, nickname and role changes |
| Auto-Moderation (Anti-Raid) | Join-rate detection and lockdown reactions |
| Leveling | Voice XP and member-bound XP state |
| Temp-Voice | Owner transfer / claim when members move |
| Stats / Invite-Tracking | Member counts and invite attribution on join |
| Birthdays | Resolve birthday role recipients |
| Tickets / Self-Roles | Permission overwrites and role grants for members |
Without this intent, welcome, verification, raid protection, and accurate member logging cannot function.
## Message Content Intent (`MessageContent`)
Required to read message text for filters, XP, custom triggers, and moderation tools.
| Module | Why |
|--------|-----|
| Auto-Moderation | Spam, mentions, caps, invites, links, word/regex filters, duplicates, emoji spam, zalgo, phishing lists |
| Leveling | Text XP from message content (with cooldowns) |
| Tags | Trigger-word custom commands |
| Logging | Edited/deleted message content in log embeds |
| Utility (`/snipe`, `/editsnipe`) | Restore last deleted/edited message content when enabled |
| Moderation (`/purge` filters) | Filter by links/regex against message body |
Slash command **options** do not need this intent; only messages sent by users in guild channels do.
## Intents that are not privileged (for reference)
Nexumi also uses non-privileged intents such as `Guilds`, `GuildMessages`, `GuildMessageReactions`, `GuildModeration`, `GuildVoiceStates`, `GuildEmojisAndStickers`, and `GuildInvites`. Those do not require special verification approval but are listed here for completeness of the gateway configuration in `apps/bot/src/index.ts`.

View File

@@ -1398,6 +1398,26 @@ export const commandLocales = {
'guildbackup.common.options.id': { 'guildbackup.common.options.id': {
de: 'Backup-ID', de: 'Backup-ID',
en: 'Backup ID' en: 'Backup ID'
},
'core.help.description': {
de: 'Command-Übersicht nach Modulen',
en: 'Command overview by module'
},
'core.help.options.module': {
de: 'Modul filtern (z. B. moderation)',
en: 'Filter by module (e.g. moderation)'
},
'core.info.description': {
de: 'Bot-Info: Version, Uptime, Shard, Links',
en: 'Bot info: version, uptime, shard, links'
},
'core.invite.description': {
de: 'Einladungslink für Nexumi',
en: 'Invite link for Nexumi'
},
'core.support.description': {
de: 'Link zum Support-Server',
en: 'Link to the support server'
} }
} as const; } as const;

View File

@@ -10,6 +10,7 @@ export * from './phase5.js';
export * from './phase6.js'; export * from './phase6.js';
export * from './dashboard.js'; export * from './dashboard.js';
export * from './owner.js'; export * from './owner.js';
export * from './public.js';
export const LocaleSchema = z.enum(['de', 'en']); export const LocaleSchema = z.enum(['de', 'en']);
export type Locale = z.infer<typeof LocaleSchema>; export type Locale = z.infer<typeof LocaleSchema>;
@@ -52,6 +53,48 @@ const de: Dictionary = {
'generic.error': 'Es ist ein Fehler aufgetreten.', 'generic.error': 'Es ist ein Fehler aufgetreten.',
'generic.maintenance': 'Nexumi ist derzeit im Wartungsmodus. Bitte versuche es später erneut.', 'generic.maintenance': 'Nexumi ist derzeit im Wartungsmodus. Bitte versuche es später erneut.',
'generic.unknownCommand': 'Unbekannter Befehl.', 'generic.unknownCommand': 'Unbekannter Befehl.',
'core.help.title': 'Nexumi Hilfe',
'core.help.subtitle': 'Commands nach Modul. Optional: `/help module:<name>`.',
'core.help.moduleLine': '**{module}**: {commands}',
'core.help.unknownModule': 'Unbekanntes Modul. Verfügbar: {modules}',
'core.help.footer': 'Mehr Infos: /info · Dashboard: {webui}',
'core.info.title': 'Nexumi',
'core.info.version': 'Version',
'core.info.uptime': 'Uptime',
'core.info.shard': 'Shard',
'core.info.guilds': 'Server (dieser Shard)',
'core.info.latency': 'API-Latenz',
'core.info.links': 'Links',
'core.info.link.dashboard': 'Dashboard',
'core.info.link.invite': 'Einladen',
'core.info.link.support': 'Support',
'core.info.link.status': 'Status',
'core.invite.title': 'Nexumi einladen',
'core.invite.body': 'Lade Nexumi mit diesem Link auf deinen Server ein:',
'core.support.title': 'Support',
'core.support.body': 'Tritt unserem Support-Server bei:',
'core.support.missing': 'Kein Support-Server konfiguriert. Bitte SUPPORT_SERVER_URL setzen.',
'core.module.core': 'Basis',
'core.module.moderation': 'Moderation',
'core.module.automod': 'Auto-Moderation',
'core.module.welcome': 'Willkommen',
'core.module.verification': 'Verifizierung',
'core.module.leveling': 'Leveling',
'core.module.economy': 'Economy',
'core.module.utility': 'Utility',
'core.module.fun': 'Fun',
'core.module.giveaways': 'Giveaways',
'core.module.tickets': 'Tickets',
'core.module.selfroles': 'Self-Roles',
'core.module.tags': 'Tags',
'core.module.starboard': 'Starboard',
'core.module.suggestions': 'Vorschläge',
'core.module.birthdays': 'Geburtstage',
'core.module.tempvoice': 'Temp-Voice',
'core.module.stats': 'Statistiken',
'core.module.feeds': 'Feeds',
'core.module.scheduler': 'Scheduler',
'core.module.guildbackup': 'Backup',
'moderation.success': 'Aktion erfolgreich ausgeführt.', 'moderation.success': 'Aktion erfolgreich ausgeführt.',
'moderation.reason.none': 'Kein Grund angegeben', 'moderation.reason.none': 'Kein Grund angegeben',
'moderation.warning.created': 'Verwarnung erstellt: {warningId}', 'moderation.warning.created': 'Verwarnung erstellt: {warningId}',
@@ -527,6 +570,48 @@ const en: Dictionary = {
'generic.error': 'An error occurred.', 'generic.error': 'An error occurred.',
'generic.maintenance': 'Nexumi is currently in maintenance mode. Please try again later.', 'generic.maintenance': 'Nexumi is currently in maintenance mode. Please try again later.',
'generic.unknownCommand': 'Unknown command.', 'generic.unknownCommand': 'Unknown command.',
'core.help.title': 'Nexumi Help',
'core.help.subtitle': 'Commands by module. Optional: `/help module:<name>`.',
'core.help.moduleLine': '**{module}**: {commands}',
'core.help.unknownModule': 'Unknown module. Available: {modules}',
'core.help.footer': 'More: /info · Dashboard: {webui}',
'core.info.title': 'Nexumi',
'core.info.version': 'Version',
'core.info.uptime': 'Uptime',
'core.info.shard': 'Shard',
'core.info.guilds': 'Servers (this shard)',
'core.info.latency': 'API latency',
'core.info.links': 'Links',
'core.info.link.dashboard': 'Dashboard',
'core.info.link.invite': 'Invite',
'core.info.link.support': 'Support',
'core.info.link.status': 'Status',
'core.invite.title': 'Invite Nexumi',
'core.invite.body': 'Invite Nexumi to your server with this link:',
'core.support.title': 'Support',
'core.support.body': 'Join our support server:',
'core.support.missing': 'No support server configured. Set SUPPORT_SERVER_URL.',
'core.module.core': 'Core',
'core.module.moderation': 'Moderation',
'core.module.automod': 'Auto-Moderation',
'core.module.welcome': 'Welcome',
'core.module.verification': 'Verification',
'core.module.leveling': 'Leveling',
'core.module.economy': 'Economy',
'core.module.utility': 'Utility',
'core.module.fun': 'Fun',
'core.module.giveaways': 'Giveaways',
'core.module.tickets': 'Tickets',
'core.module.selfroles': 'Self-Roles',
'core.module.tags': 'Tags',
'core.module.starboard': 'Starboard',
'core.module.suggestions': 'Suggestions',
'core.module.birthdays': 'Birthdays',
'core.module.tempvoice': 'Temp Voice',
'core.module.stats': 'Stats',
'core.module.feeds': 'Feeds',
'core.module.scheduler': 'Scheduler',
'core.module.guildbackup': 'Backup',
'moderation.success': 'Action executed successfully.', 'moderation.success': 'Action executed successfully.',
'moderation.reason.none': 'No reason provided', 'moderation.reason.none': 'No reason provided',
'moderation.warning.created': 'Warning created: {warningId}', 'moderation.warning.created': 'Warning created: {warningId}',

View File

@@ -0,0 +1,28 @@
import { z } from 'zod';
export const PublicStatsSchema = z.object({
guildCount: z.number().int().nonnegative(),
approximateUserCount: z.number().int().nonnegative(),
updatedAt: z.string()
});
export type PublicStats = z.infer<typeof PublicStatsSchema>;
export const BotStatusShardSchema = z.object({
id: z.number().int().nonnegative(),
status: z.string(),
guildCount: z.number().int().nonnegative(),
ping: z.number()
});
export const BotStatusPayloadSchema = z.object({
shards: z.array(BotStatusShardSchema),
apiLatencyMs: z.number(),
guildCount: z.number().int().nonnegative(),
uptimeSeconds: z.number(),
updatedAt: z.string()
});
export type BotStatusPayload = z.infer<typeof BotStatusPayloadSchema>;
export const BOT_STATUS_REDIS_KEY = 'bot:status';