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:
@@ -25,3 +25,10 @@ WEBUI_URL=http://10.111.0.65:3000
|
||||
SESSION_SECRET=
|
||||
# Comma-separated Discord user IDs treated as global bot owners (owner panel, Phase 7 Batch C).
|
||||
OWNER_USER_IDS=
|
||||
|
||||
# Public site / bot links (Phase 8)
|
||||
SUPPORT_SERVER_URL=
|
||||
LEGAL_OPERATOR_NAME=
|
||||
LEGAL_OPERATOR_ADDRESS=
|
||||
LEGAL_OPERATOR_EMAIL=
|
||||
LEGAL_OPERATOR_PHONE=
|
||||
|
||||
@@ -29,9 +29,11 @@ import { statsCommands } from './modules/stats/index.js';
|
||||
import { feedsCommands } from './modules/feeds/index.js';
|
||||
import { scheduleCommands } from './modules/scheduler/index.js';
|
||||
import { guildBackupCommands } from './modules/guildbackup/index.js';
|
||||
import { coreCommands } from './modules/core/index.js';
|
||||
import type { BotContext, SlashCommand } from './types.js';
|
||||
|
||||
const commands: SlashCommand[] = [
|
||||
...coreCommands,
|
||||
...moderationCommands,
|
||||
...automodCommands,
|
||||
...welcomeCommands,
|
||||
|
||||
@@ -36,7 +36,17 @@ const EnvSchema = z.object({
|
||||
.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 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();
|
||||
}
|
||||
|
||||
@@ -120,10 +120,11 @@ if (!isShard) {
|
||||
logger.error({ error }, 'Failed to register application commands');
|
||||
}
|
||||
try {
|
||||
const { applyBotPresence } = await import('./presence.js');
|
||||
const { applyBotPresence, publishBotStatus } = await import('./presence.js');
|
||||
await applyBotPresence(context);
|
||||
await publishBotStatus(context);
|
||||
} catch (error) {
|
||||
logger.warn({ error }, 'Failed to apply initial bot presence');
|
||||
logger.warn({ error }, 'Failed to apply initial bot presence/status');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
24
apps/bot/src/modules/core/command-definitions.ts
Normal file
24
apps/bot/src/modules/core/command-definitions.ts
Normal 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'
|
||||
);
|
||||
158
apps/bot/src/modules/core/commands.ts
Normal file
158
apps/bot/src/modules/core/commands.ts
Normal 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
|
||||
];
|
||||
93
apps/bot/src/modules/core/help-catalog.ts
Normal file
93
apps/bot/src/modules/core/help-catalog.ts
Normal 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'] }
|
||||
];
|
||||
2
apps/bot/src/modules/core/index.ts
Normal file
2
apps/bot/src/modules/core/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { coreCommands } from './commands.js';
|
||||
export { HELP_MODULES } from './help-catalog.js';
|
||||
@@ -1,9 +1,87 @@
|
||||
import { ActivityType } from 'discord.js';
|
||||
import { PRESENCE_REDIS_KEY, BotPresenceConfigSchema, type BotPresenceConfig } from '@nexumi/shared';
|
||||
import { ActivityType, Status } from 'discord.js';
|
||||
import {
|
||||
BOT_STATUS_REDIS_KEY,
|
||||
PRESENCE_REDIS_KEY,
|
||||
BotPresenceConfigSchema,
|
||||
BotStatusPayloadSchema,
|
||||
type BotPresenceConfig
|
||||
} from '@nexumi/shared';
|
||||
import { redis } from './redis.js';
|
||||
import { logger } from './logger.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> = {
|
||||
Playing: ActivityType.Playing,
|
||||
Listening: ActivityType.Listening,
|
||||
@@ -75,4 +153,9 @@ export async function runPresenceRefreshJob(context: BotContext): Promise<void>
|
||||
} catch (error) {
|
||||
logger.warn({ error }, 'Failed to apply bot presence');
|
||||
}
|
||||
try {
|
||||
await publishBotStatus(context);
|
||||
} catch (error) {
|
||||
logger.warn({ error }, 'Failed to publish bot status');
|
||||
}
|
||||
}
|
||||
|
||||
37
apps/webui/public/nexumi-logo.svg
Normal file
37
apps/webui/public/nexumi-logo.svg
Normal 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 |
14
apps/webui/src/app/api/public/stats/route.ts
Normal file
14
apps/webui/src/app/api/public/stats/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
14
apps/webui/src/app/api/public/status/route.ts
Normal file
14
apps/webui/src/app/api/public/status/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
46
apps/webui/src/app/impressum/page.tsx
Normal file
46
apps/webui/src/app/impressum/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -10,8 +10,8 @@ import './globals.css';
|
||||
const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Nexumi Dashboard',
|
||||
description: 'Manage your Nexumi Discord bot'
|
||||
title: 'Nexumi — Discord Bot',
|
||||
description: 'Self-hosted Discord bot with dashboard, moderation, and community modules'
|
||||
};
|
||||
|
||||
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
export default async function RootPage() {
|
||||
const session = await getSession();
|
||||
redirect(session ? '/dashboard' : '/login');
|
||||
const GROUP_ORDER: DashboardModuleGroup[] = [
|
||||
'moderation',
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
17
apps/webui/src/app/privacy/page.tsx
Normal file
17
apps/webui/src/app/privacy/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
apps/webui/src/app/status/page.tsx
Normal file
120
apps/webui/src/app/status/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
apps/webui/src/app/terms/page.tsx
Normal file
17
apps/webui/src/app/terms/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
apps/webui/src/components/site/legal-shell.tsx
Normal file
39
apps/webui/src/components/site/legal-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
apps/webui/src/components/site/site-footer.tsx
Normal file
31
apps/webui/src/components/site/site-footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
apps/webui/src/components/site/site-header.tsx
Normal file
62
apps/webui/src/components/site/site-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
apps/webui/src/components/site/site-shell.tsx
Normal file
21
apps/webui/src/components/site/site-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -3,28 +3,20 @@ import { z } from 'zod';
|
||||
|
||||
config();
|
||||
|
||||
const emptyToUndefined = (value: unknown) =>
|
||||
value === '' || value === undefined || value === null ? undefined : value;
|
||||
|
||||
const EnvSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
DATABASE_URL: z.string().url(),
|
||||
REDIS_URL: z.string().url(),
|
||||
BOT_CLIENT_ID: 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),
|
||||
WEBUI_URL: z.string().url().default('http://localhost:3000'),
|
||||
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'),
|
||||
/**
|
||||
* 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(
|
||||
(value) => (typeof value === 'string' && value.trim() === '' ? undefined : value),
|
||||
z
|
||||
@@ -38,8 +30,21 @@ const EnvSchema = z.object({
|
||||
.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 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();
|
||||
}
|
||||
|
||||
24
apps/webui/src/lib/public-stats.ts
Normal file
24
apps/webui/src/lib/public-stats.ts
Normal 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()
|
||||
};
|
||||
}
|
||||
56
apps/webui/src/lib/public-status.ts
Normal file
56
apps/webui/src/lib/public-status.ts
Normal 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()
|
||||
};
|
||||
}
|
||||
@@ -36,26 +36,86 @@
|
||||
"integrations": "Integrationen"
|
||||
},
|
||||
"modules": {
|
||||
"moderation": { "label": "Moderation", "description": "Bans, Verwarnungen, Timeouts und das Fall-System" },
|
||||
"automod": { "label": "Auto-Moderation", "description": "Spam-, Raid- und Nuke-Schutz" },
|
||||
"logging": { "label": "Logging", "description": "Log-Kanäle für Server-Events" },
|
||||
"welcome": { "label": "Willkommen & Abschied", "description": "Join- und Leave-Nachrichten, Autoroles" },
|
||||
"verification": { "label": "Verifizierung", "description": "Button- und Captcha-Verifizierung" },
|
||||
"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" }
|
||||
"moderation": {
|
||||
"label": "Moderation",
|
||||
"description": "Bans, Verwarnungen, Timeouts und das Fall-System"
|
||||
},
|
||||
"automod": {
|
||||
"label": "Auto-Moderation",
|
||||
"description": "Spam-, Raid- und Nuke-Schutz"
|
||||
},
|
||||
"logging": {
|
||||
"label": "Logging",
|
||||
"description": "Log-Kanäle für Server-Events"
|
||||
},
|
||||
"welcome": {
|
||||
"label": "Willkommen & Abschied",
|
||||
"description": "Join- und Leave-Nachrichten, Autoroles"
|
||||
},
|
||||
"verification": {
|
||||
"label": "Verifizierung",
|
||||
"description": "Button- und Captcha-Verifizierung"
|
||||
},
|
||||
"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": {
|
||||
"title": "Nexumi",
|
||||
@@ -141,7 +201,11 @@
|
||||
"addEscalation": "Regel hinzufügen",
|
||||
"warnCount": "Anzahl Verwarnungen",
|
||||
"action": "Aktion",
|
||||
"actions": { "TIMEOUT": "Timeout", "KICK": "Kick", "BAN": "Ban" },
|
||||
"actions": {
|
||||
"TIMEOUT": "Timeout",
|
||||
"KICK": "Kick",
|
||||
"BAN": "Ban"
|
||||
},
|
||||
"durationMs": "Dauer (ms)",
|
||||
"reason": "Grund",
|
||||
"reasonPlaceholder": "Optionaler Standardgrund",
|
||||
@@ -197,7 +261,11 @@
|
||||
"welcomeEnabledLabel": "Willkommensnachricht aktiviert",
|
||||
"welcomeChannelId": "Willkommens-Kanal-ID",
|
||||
"welcomeType": "Nachrichtentyp",
|
||||
"type": { "TEXT": "Text", "EMBED": "Embed", "IMAGE": "Bild-Karte" },
|
||||
"type": {
|
||||
"TEXT": "Text",
|
||||
"EMBED": "Embed",
|
||||
"IMAGE": "Bild-Karte"
|
||||
},
|
||||
"welcomeContent": "Nachrichtentext",
|
||||
"contentPlaceholder": "Willkommen {user} auf {server}!",
|
||||
"placeholdersHint": "Verfügbare Platzhalter: {user}, {user.name}, {user.tag}, {user.id}, {server}, {memberCount}",
|
||||
@@ -337,7 +405,11 @@
|
||||
"channelId": "Kanal-ID",
|
||||
"panelTitle": "Panel-Titel",
|
||||
"mode": "Modus",
|
||||
"modes": { "BUTTONS": "Buttons", "DROPDOWN": "Dropdown", "REACTIONS": "Reaktionen" },
|
||||
"modes": {
|
||||
"BUTTONS": "Buttons",
|
||||
"DROPDOWN": "Dropdown",
|
||||
"REACTIONS": "Reaktionen"
|
||||
},
|
||||
"behavior": "Verhalten",
|
||||
"behaviors": {
|
||||
"NORMAL": "Normal (frei umschaltbar)",
|
||||
@@ -361,7 +433,10 @@
|
||||
"createDescription": "Custom Commands und Auto-Responder, ausgelöst per Name oder Stichwort.",
|
||||
"name": "Name",
|
||||
"responseType": "Antworttyp",
|
||||
"responseTypes": { "TEXT": "Text", "EMBED": "Embed" },
|
||||
"responseTypes": {
|
||||
"TEXT": "Text",
|
||||
"EMBED": "Embed"
|
||||
},
|
||||
"triggerWord": "Auslöse-Wort (optional)",
|
||||
"content": "Inhalt",
|
||||
"allowedRoleIds": "Erlaubte Rollen-IDs",
|
||||
@@ -620,5 +695,88 @@
|
||||
"forbidden": "Du hast keinen Zugriff auf diesen Server.",
|
||||
"notFound": "Nicht gefunden.",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,26 +36,86 @@
|
||||
"integrations": "Integrations"
|
||||
},
|
||||
"modules": {
|
||||
"moderation": { "label": "Moderation", "description": "Bans, warns, timeouts and the case system" },
|
||||
"automod": { "label": "Auto-Moderation", "description": "Spam, raid and nuke protection" },
|
||||
"logging": { "label": "Logging", "description": "Server event log channels" },
|
||||
"welcome": { "label": "Welcome & Leave", "description": "Join and leave messages, autoroles" },
|
||||
"verification": { "label": "Verification", "description": "Button and captcha verification" },
|
||||
"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" }
|
||||
"moderation": {
|
||||
"label": "Moderation",
|
||||
"description": "Bans, warns, timeouts and the case system"
|
||||
},
|
||||
"automod": {
|
||||
"label": "Auto-Moderation",
|
||||
"description": "Spam, raid and nuke protection"
|
||||
},
|
||||
"logging": {
|
||||
"label": "Logging",
|
||||
"description": "Server event log channels"
|
||||
},
|
||||
"welcome": {
|
||||
"label": "Welcome & Leave",
|
||||
"description": "Join and leave messages, autoroles"
|
||||
},
|
||||
"verification": {
|
||||
"label": "Verification",
|
||||
"description": "Button and captcha verification"
|
||||
},
|
||||
"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": {
|
||||
"title": "Nexumi",
|
||||
@@ -141,7 +201,11 @@
|
||||
"addEscalation": "Add rule",
|
||||
"warnCount": "Warning count",
|
||||
"action": "Action",
|
||||
"actions": { "TIMEOUT": "Timeout", "KICK": "Kick", "BAN": "Ban" },
|
||||
"actions": {
|
||||
"TIMEOUT": "Timeout",
|
||||
"KICK": "Kick",
|
||||
"BAN": "Ban"
|
||||
},
|
||||
"durationMs": "Duration (ms)",
|
||||
"reason": "Reason",
|
||||
"reasonPlaceholder": "Optional default reason",
|
||||
@@ -197,7 +261,11 @@
|
||||
"welcomeEnabledLabel": "Welcome message enabled",
|
||||
"welcomeChannelId": "Welcome channel ID",
|
||||
"welcomeType": "Message type",
|
||||
"type": { "TEXT": "Text", "EMBED": "Embed", "IMAGE": "Image card" },
|
||||
"type": {
|
||||
"TEXT": "Text",
|
||||
"EMBED": "Embed",
|
||||
"IMAGE": "Image card"
|
||||
},
|
||||
"welcomeContent": "Message content",
|
||||
"contentPlaceholder": "Welcome {user} to {server}!",
|
||||
"placeholdersHint": "Available placeholders: {user}, {user.name}, {user.tag}, {user.id}, {server}, {memberCount}",
|
||||
@@ -337,7 +405,11 @@
|
||||
"channelId": "Channel ID",
|
||||
"panelTitle": "Panel title",
|
||||
"mode": "Mode",
|
||||
"modes": { "BUTTONS": "Buttons", "DROPDOWN": "Dropdown", "REACTIONS": "Reactions" },
|
||||
"modes": {
|
||||
"BUTTONS": "Buttons",
|
||||
"DROPDOWN": "Dropdown",
|
||||
"REACTIONS": "Reactions"
|
||||
},
|
||||
"behavior": "Behavior",
|
||||
"behaviors": {
|
||||
"NORMAL": "Normal (toggle freely)",
|
||||
@@ -361,7 +433,10 @@
|
||||
"createDescription": "Custom commands and auto-responders triggered by name or keyword.",
|
||||
"name": "Name",
|
||||
"responseType": "Response type",
|
||||
"responseTypes": { "TEXT": "Text", "EMBED": "Embed" },
|
||||
"responseTypes": {
|
||||
"TEXT": "Text",
|
||||
"EMBED": "Embed"
|
||||
},
|
||||
"triggerWord": "Trigger word (optional)",
|
||||
"content": "Content",
|
||||
"allowedRoleIds": "Allowed role IDs",
|
||||
@@ -620,5 +695,88 @@
|
||||
"forbidden": "You do not have access to this server.",
|
||||
"notFound": "Not found.",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,23 +3,42 @@
|
||||
- OAuth/Sessions, Settings-Framework, Layout, Modul-Toggles, Access-Rules
|
||||
- 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
|
||||
|
||||
- **Batch A:** Moderation, Automod, Logging, Welcome, Verification, Leveling, Economy, Fun + Prisma Owner-Modelle (`20260722190000_phase7_owner`)
|
||||
- **Batch B:** Tickets, Giveaways, SelfRoles, Tags, Starboard, Suggestions, Birthdays, TempVoice, Stats, Feeds, Scheduler, Backup, Commands-Seite, Overview-Aktivität; BullMQ-Anbindung WebUI↔Bot
|
||||
- **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`
|
||||
- Shared: `dashboard.ts`, `owner.ts` + Tests
|
||||
- Checks: shared 46 Tests, webui typecheck/lint, bot typecheck grün
|
||||
- Alle Modul-Dashboard-Seiten, Commands-Seite, Overview-Aktivität
|
||||
- Owner-Panel (`/owner`: Übersicht, Guilds, Users, Flags, Presence, Team, Jobs, Changelog, Audit)
|
||||
- Prisma `20260722190000_phase7_owner`, Shared Zod (`dashboard.ts`, `owner.ts`)
|
||||
- BullMQ WebUI↔Bot, Presence-/Wartungsmodus-Anbindung
|
||||
- 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
|
||||
|
||||
1. `OWNER_USER_IDS=<deine Discord-User-ID>` in `.env` setzen, Stack neu starten
|
||||
2. Dashboard-Modul-Seiten speichern (Settings/Module/Cases etc.)
|
||||
3. Owner-Panel: `/owner` → Presence, Flags, Team, Jobs
|
||||
4. Giveaway/Suggestion/Backup-Aktionen aus dem Dashboard (BullMQ)
|
||||
- [ ] Landing unter `WEBUI_URL` (nicht localhost): Stats, Invite, Login
|
||||
- [ ] `/status` zeigt Shards nach Bot-Start (sonst „degraded“ bis Redis-Status da ist)
|
||||
- [ ] Impressum: mit/ohne `LEGAL_OPERATOR_*` in `.env`
|
||||
- [ ] 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
|
||||
|
||||
- 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
41
docs/verification.md
Normal 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`.
|
||||
@@ -1398,6 +1398,26 @@ export const commandLocales = {
|
||||
'guildbackup.common.options.id': {
|
||||
de: '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;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export * from './phase5.js';
|
||||
export * from './phase6.js';
|
||||
export * from './dashboard.js';
|
||||
export * from './owner.js';
|
||||
export * from './public.js';
|
||||
|
||||
export const LocaleSchema = z.enum(['de', 'en']);
|
||||
export type Locale = z.infer<typeof LocaleSchema>;
|
||||
@@ -52,6 +53,48 @@ const de: Dictionary = {
|
||||
'generic.error': 'Es ist ein Fehler aufgetreten.',
|
||||
'generic.maintenance': 'Nexumi ist derzeit im Wartungsmodus. Bitte versuche es später erneut.',
|
||||
'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.reason.none': 'Kein Grund angegeben',
|
||||
'moderation.warning.created': 'Verwarnung erstellt: {warningId}',
|
||||
@@ -527,6 +570,48 @@ const en: Dictionary = {
|
||||
'generic.error': 'An error occurred.',
|
||||
'generic.maintenance': 'Nexumi is currently in maintenance mode. Please try again later.',
|
||||
'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.reason.none': 'No reason provided',
|
||||
'moderation.warning.created': 'Warning created: {warningId}',
|
||||
|
||||
28
packages/shared/src/public.ts
Normal file
28
packages/shared/src/public.ts
Normal 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';
|
||||
Reference in New Issue
Block a user