diff --git a/.env.example b/.env.example index 9929571..60e478a 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/apps/bot/src/commands.ts b/apps/bot/src/commands.ts index 4855daa..8aee716 100644 --- a/apps/bot/src/commands.ts +++ b/apps/bot/src/commands.ts @@ -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, diff --git a/apps/bot/src/env.ts b/apps/bot/src/env.ts index 82ce65d..8fd549d 100644 --- a/apps/bot/src/env.ts +++ b/apps/bot/src/env.ts @@ -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(); +} diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index cdf6dbe..05dda92 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -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'); } }); diff --git a/apps/bot/src/modules/core/command-definitions.ts b/apps/bot/src/modules/core/command-definitions.ts new file mode 100644 index 0000000..7901d59 --- /dev/null +++ b/apps/bot/src/modules/core/command-definitions.ts @@ -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' +); diff --git a/apps/bot/src/modules/core/commands.ts b/apps/bot/src/modules/core/commands.ts new file mode 100644 index 0000000..3bd9e52 --- /dev/null +++ b/apps/bot/src/modules/core/commands.ts @@ -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 +]; diff --git a/apps/bot/src/modules/core/help-catalog.ts b/apps/bot/src/modules/core/help-catalog.ts new file mode 100644 index 0000000..2f210be --- /dev/null +++ b/apps/bot/src/modules/core/help-catalog.ts @@ -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'] } +]; diff --git a/apps/bot/src/modules/core/index.ts b/apps/bot/src/modules/core/index.ts new file mode 100644 index 0000000..9d0d173 --- /dev/null +++ b/apps/bot/src/modules/core/index.ts @@ -0,0 +1,2 @@ +export { coreCommands } from './commands.js'; +export { HELP_MODULES } from './help-catalog.js'; diff --git a/apps/bot/src/presence.ts b/apps/bot/src/presence.ts index 03eef53..c94bdff 100644 --- a/apps/bot/src/presence.ts +++ b/apps/bot/src/presence.ts @@ -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 { + 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 => 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 = { Playing: ActivityType.Playing, Listening: ActivityType.Listening, @@ -75,4 +153,9 @@ export async function runPresenceRefreshJob(context: BotContext): Promise } catch (error) { logger.warn({ error }, 'Failed to apply bot presence'); } + try { + await publishBotStatus(context); + } catch (error) { + logger.warn({ error }, 'Failed to publish bot status'); + } } diff --git a/apps/webui/public/nexumi-logo.svg b/apps/webui/public/nexumi-logo.svg new file mode 100644 index 0000000..210c5a6 --- /dev/null +++ b/apps/webui/public/nexumi-logo.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/webui/src/app/api/public/stats/route.ts b/apps/webui/src/app/api/public/stats/route.ts new file mode 100644 index 0000000..2b48cdd --- /dev/null +++ b/apps/webui/src/app/api/public/stats/route.ts @@ -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 }); + } +} diff --git a/apps/webui/src/app/api/public/status/route.ts b/apps/webui/src/app/api/public/status/route.ts new file mode 100644 index 0000000..9b81c75 --- /dev/null +++ b/apps/webui/src/app/api/public/status/route.ts @@ -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 }); + } +} diff --git a/apps/webui/src/app/impressum/page.tsx b/apps/webui/src/app/impressum/page.tsx new file mode 100644 index 0000000..99753b5 --- /dev/null +++ b/apps/webui/src/app/impressum/page.tsx @@ -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 ( + +

{t(locale, 'legal.impressum.intro')}

+ {configured ? ( +
+
+
{t(locale, 'legal.impressum.name')}
+
{env.LEGAL_OPERATOR_NAME}
+
+
+
{t(locale, 'legal.impressum.address')}
+
{env.LEGAL_OPERATOR_ADDRESS}
+
+
+
{t(locale, 'legal.impressum.email')}
+
+ + {env.LEGAL_OPERATOR_EMAIL} + +
+
+ {env.LEGAL_OPERATOR_PHONE ? ( +
+
{t(locale, 'legal.impressum.phone')}
+
{env.LEGAL_OPERATOR_PHONE}
+
+ ) : null} +
+ ) : ( +

+ {t(locale, 'legal.impressum.notConfigured')} +

+ )} +

{t(locale, 'legal.impressum.p1')}

+

{t(locale, 'legal.impressum.p2')}

+
+ ); +} diff --git a/apps/webui/src/app/layout.tsx b/apps/webui/src/app/layout.tsx index 8fdc879..1ce16e5 100644 --- a/apps/webui/src/app/layout.tsx +++ b/apps/webui/src/app/layout.tsx @@ -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 }) { diff --git a/apps/webui/src/app/page.tsx b/apps/webui/src/app/page.tsx index a2759c5..59bd845 100644 --- a/apps/webui/src/app/page.tsx +++ b/apps/webui/src/app/page.tsx @@ -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 ( + +
+
+
+ +

Nexumi

+
+

{t(locale, 'landing.hero.subtitle')}

+
+ + + {env.SUPPORT_SERVER_URL ? ( + + ) : null} +
+
+
+ +
+
+ + + + {t(locale, 'landing.stats.guilds')} + + + {stats.guildCount.toLocaleString(locale)} + + + + + {t(locale, 'landing.stats.users')} + + + + {stats.approximateUserCount.toLocaleString(locale)} + + +
+
+ +
+
+
+

{t(locale, 'landing.features.title')}

+

{t(locale, 'landing.features.subtitle')}

+
+ {GROUP_ORDER.map((group) => { + const modules = DASHBOARD_MODULES.filter((module) => module.group === group); + return ( +
+

+ {t(locale, `moduleGroups.${group}`)} +

+
+ {modules.map((module) => ( + + + {t(locale, `modules.${module.id}.label`)} + + + {t(locale, `modules.${module.id}.description`)} + + + ))} +
+
+ ); + })} +
+
+
+ ); } diff --git a/apps/webui/src/app/privacy/page.tsx b/apps/webui/src/app/privacy/page.tsx new file mode 100644 index 0000000..6612860 --- /dev/null +++ b/apps/webui/src/app/privacy/page.tsx @@ -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 ( + +

{t(locale, 'legal.scaffoldNotice')}

+ {['p1', 'p2', 'p3', 'p4', 'p5'].map((key) => ( +

+ {t(locale, `legal.privacy.${key}`)} +

+ ))} +
+ ); +} diff --git a/apps/webui/src/app/status/page.tsx b/apps/webui/src/app/status/page.tsx new file mode 100644 index 0000000..1134fa4 --- /dev/null +++ b/apps/webui/src/app/status/page.tsx @@ -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 ( + +
+
+

{t(locale, 'status.title')}

+ + {t(locale, `status.overall.${status.overall}`)} + +
+

{t(locale, 'status.subtitle')}

+ +
+ + + {t(locale, 'status.latency')} + + + {status.apiLatencyMs === null ? '—' : `${status.apiLatencyMs} ms`} + + + + + {t(locale, 'status.guilds')} + + {status.guildCount} + + + + {t(locale, 'status.uptime')} + + + {status.uptimeSeconds === null + ? '—' + : `${Math.floor(status.uptimeSeconds / 3600)}h ${Math.floor((status.uptimeSeconds % 3600) / 60)}m`} + + +
+ +
+

{t(locale, 'status.shardsTitle')}

+ {status.shards.length === 0 ? ( + + + {t(locale, 'status.shardsEmpty')} + + + ) : ( +
+ {status.shards.map((shard) => ( + + + + {t(locale, 'status.shardLabel', { id: shard.id })} + + + +

+ {t(locale, 'status.shardStatus')}: {shard.status} +

+

+ {t(locale, 'status.guilds')}: {shard.guildCount} +

+

+ {t(locale, 'status.latency')}: {shard.ping} ms +

+
+
+ ))} +
+ )} +
+ +
+

{t(locale, 'status.updatesTitle')}

+ + + {status.updates.length === 0 ? ( +

{t(locale, 'status.updatesEmpty')}

+ ) : ( + status.updates.map((entry) => ( +
+
+

+ {entry.version} — {entry.title} +

+ {entry.isIncident ? {t(locale, 'status.incident')} : null} +
+

{entry.body}

+

+ {new Date(entry.publishedAt).toLocaleString(locale)} +

+
+ )) + )} +
+
+
+
+
+ ); +} diff --git a/apps/webui/src/app/terms/page.tsx b/apps/webui/src/app/terms/page.tsx new file mode 100644 index 0000000..4eefa9f --- /dev/null +++ b/apps/webui/src/app/terms/page.tsx @@ -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 ( + +

{t(locale, 'legal.scaffoldNotice')}

+ {['p1', 'p2', 'p3', 'p4'].map((key) => ( +

+ {t(locale, `legal.terms.${key}`)} +

+ ))} +
+ ); +} diff --git a/apps/webui/src/components/site/legal-shell.tsx b/apps/webui/src/components/site/legal-shell.tsx new file mode 100644 index 0000000..c98a3cb --- /dev/null +++ b/apps/webui/src/components/site/legal-shell.tsx @@ -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 ( + +
+ +
+

{title}

+ {children} +
+
+
+ ); +} diff --git a/apps/webui/src/components/site/site-footer.tsx b/apps/webui/src/components/site/site-footer.tsx new file mode 100644 index 0000000..54ee863 --- /dev/null +++ b/apps/webui/src/components/site/site-footer.tsx @@ -0,0 +1,31 @@ +import Link from 'next/link'; +import { getLocale, t } from '@/lib/i18n'; + +export async function SiteFooter() { + const locale = await getLocale(); + + return ( +
+
+

© {new Date().getFullYear()} Nexumi

+ +
+
+ ); +} diff --git a/apps/webui/src/components/site/site-header.tsx b/apps/webui/src/components/site/site-header.tsx new file mode 100644 index 0000000..92f6413 --- /dev/null +++ b/apps/webui/src/components/site/site-header.tsx @@ -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 ( +
+
+ + Nexumi + Nexumi + + +
+
+ ); +} diff --git a/apps/webui/src/components/site/site-shell.tsx b/apps/webui/src/components/site/site-shell.tsx new file mode 100644 index 0000000..1053f70 --- /dev/null +++ b/apps/webui/src/components/site/site-shell.tsx @@ -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 ( +
+ +
{children}
+ +
+ ); +} diff --git a/apps/webui/src/lib/env.ts b/apps/webui/src/lib/env.ts index fbbe3e0..1834b5d 100644 --- a/apps/webui/src/lib/env.ts +++ b/apps/webui/src/lib/env.ts @@ -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(); +} diff --git a/apps/webui/src/lib/public-stats.ts b/apps/webui/src/lib/public-stats.ts new file mode 100644 index 0000000..a6ee93a --- /dev/null +++ b/apps/webui/src/lib/public-stats.ts @@ -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(); + 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() + }; +} diff --git a/apps/webui/src/lib/public-status.ts b/apps/webui/src/lib/public-status.ts new file mode 100644 index 0000000..dc2671c --- /dev/null +++ b/apps/webui/src/lib/public-status.ts @@ -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() + }; +} diff --git a/apps/webui/src/messages/de.json b/apps/webui/src/messages/de.json index e2c8fff..8174ffc 100644 --- a/apps/webui/src/messages/de.json +++ b/apps/webui/src/messages/de.json @@ -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." + } } } diff --git a/apps/webui/src/messages/en.json b/apps/webui/src/messages/en.json index 939e3f6..b0b46c6 100644 --- a/apps/webui/src/messages/en.json +++ b/apps/webui/src/messages/en.json @@ -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." + } } } diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index 9d3b511..6784d7c 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -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=` 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. diff --git a/docs/verification.md b/docs/verification.md new file mode 100644 index 0000000..72c3cb5 --- /dev/null +++ b/docs/verification.md @@ -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`. diff --git a/packages/shared/src/command-locales.ts b/packages/shared/src/command-locales.ts index 1126fd7..3bb52e7 100644 --- a/packages/shared/src/command-locales.ts +++ b/packages/shared/src/command-locales.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; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0dd73c5..dac357e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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; @@ -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:`.', + '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:`.', + '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}', diff --git a/packages/shared/src/public.ts b/packages/shared/src/public.ts new file mode 100644 index 0000000..d9e5a26 --- /dev/null +++ b/packages/shared/src/public.ts @@ -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; + +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; + +export const BOT_STATUS_REDIS_KEY = 'bot:status';