deploy #2
@@ -0,0 +1,12 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "BotAboutConfig" (
|
||||||
|
"id" TEXT NOT NULL DEFAULT 'singleton',
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"messageType" TEXT NOT NULL DEFAULT 'EMBED',
|
||||||
|
"content" TEXT,
|
||||||
|
"embed" JSONB,
|
||||||
|
"components" JSONB,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "BotAboutConfig_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
@@ -914,6 +914,18 @@ model BotPresenceConfig {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Global `/about` message (Owner panel). Singleton row id = "singleton".
|
||||||
|
model BotAboutConfig {
|
||||||
|
id String @id @default("singleton")
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
/// TEXT | EMBED | COMPONENTS_V2
|
||||||
|
messageType String @default("EMBED")
|
||||||
|
content String?
|
||||||
|
embed Json?
|
||||||
|
components Json?
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
model ChangelogEntry {
|
model ChangelogEntry {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
version String
|
version String
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ describe('moduleForCommand', () => {
|
|||||||
it('leaves core commands unmapped', () => {
|
it('leaves core commands unmapped', () => {
|
||||||
expect(moduleForCommand('help')).toBeNull();
|
expect(moduleForCommand('help')).toBeNull();
|
||||||
expect(moduleForCommand('info')).toBeNull();
|
expect(moduleForCommand('info')).toBeNull();
|
||||||
|
expect(moduleForCommand('about')).toBeNull();
|
||||||
expect(moduleForCommand('gdpr')).toBeNull();
|
expect(moduleForCommand('gdpr')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ async function loadPayload(
|
|||||||
}
|
}
|
||||||
return parseMessageComponentsV2(binding.payload);
|
return parseMessageComponentsV2(binding.payload);
|
||||||
}
|
}
|
||||||
|
case 'a': {
|
||||||
|
if (ref !== 'singleton') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const config = await context.prisma.botAboutConfig.findUnique({ where: { id: 'singleton' } });
|
||||||
|
return parseMessageComponentsV2(config?.components);
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
98
apps/bot/src/modules/core/about.ts
Normal file
98
apps/bot/src/modules/core/about.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
ABOUT_REDIS_KEY,
|
||||||
|
BotAboutConfigSchema,
|
||||||
|
expandBracketChannelMentions,
|
||||||
|
parseMessageComponentsV2,
|
||||||
|
t,
|
||||||
|
type BotAboutConfig,
|
||||||
|
type Locale,
|
||||||
|
type WelcomeEmbed
|
||||||
|
} from '@nexumi/shared';
|
||||||
|
import type { InteractionReplyOptions, MessageCreateOptions } from 'discord.js';
|
||||||
|
import { EmbedBuilder } from 'discord.js';
|
||||||
|
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
|
||||||
|
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
|
||||||
|
import { redis } from '../../redis.js';
|
||||||
|
import type { BotContext } from '../../types.js';
|
||||||
|
|
||||||
|
const NEXUMI_COLOR = 0x6366f1;
|
||||||
|
|
||||||
|
function parseEmbed(raw: unknown): WelcomeEmbed | null {
|
||||||
|
if (!raw || typeof raw !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return raw as WelcomeEmbed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readAboutConfig(context: BotContext): Promise<BotAboutConfig> {
|
||||||
|
const cached = await redis.get(ABOUT_REDIS_KEY);
|
||||||
|
if (cached) {
|
||||||
|
const parsed = BotAboutConfigSchema.safeParse(JSON.parse(cached));
|
||||||
|
if (parsed.success) {
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await context.prisma.botAboutConfig.upsert({
|
||||||
|
where: { id: 'singleton' },
|
||||||
|
create: { id: 'singleton' },
|
||||||
|
update: {}
|
||||||
|
});
|
||||||
|
const config = BotAboutConfigSchema.parse({
|
||||||
|
enabled: row.enabled,
|
||||||
|
messageType: row.messageType,
|
||||||
|
content: row.content,
|
||||||
|
embed: parseEmbed(row.embed),
|
||||||
|
components: parseMessageComponentsV2(row.components)
|
||||||
|
});
|
||||||
|
await redis.set(ABOUT_REDIS_KEY, JSON.stringify(config));
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultAboutReply(locale: Locale): InteractionReplyOptions {
|
||||||
|
return {
|
||||||
|
embeds: [
|
||||||
|
new EmbedBuilder()
|
||||||
|
.setTitle(t(locale, 'core.about.defaultTitle'))
|
||||||
|
.setDescription(t(locale, 'core.about.defaultBody'))
|
||||||
|
.setColor(NEXUMI_COLOR)
|
||||||
|
.setFooter({ text: 'Nexumi' })
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildAboutReply(
|
||||||
|
context: BotContext,
|
||||||
|
locale: Locale
|
||||||
|
): Promise<InteractionReplyOptions | MessageCreateOptions> {
|
||||||
|
const config = await readAboutConfig(context);
|
||||||
|
if (!config.enabled) {
|
||||||
|
return { content: t(locale, 'core.about.disabled'), ephemeral: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderText = (value: string) => expandBracketChannelMentions(value);
|
||||||
|
|
||||||
|
if (config.messageType === 'COMPONENTS_V2') {
|
||||||
|
const payload = buildComponentsV2Payload(config.components, {
|
||||||
|
source: 'a',
|
||||||
|
ref: 'singleton',
|
||||||
|
renderText
|
||||||
|
});
|
||||||
|
if (payload) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.messageType === 'EMBED') {
|
||||||
|
const embed = buildEmbedFromPayload(config.embed, { renderText });
|
||||||
|
if (embed) {
|
||||||
|
return { embeds: [embed] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.messageType === 'TEXT' && config.content?.trim()) {
|
||||||
|
return { content: renderText(config.content) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultAboutReply(locale);
|
||||||
|
}
|
||||||
@@ -13,6 +13,11 @@ export const infoCommandData = applyCommandDescription(
|
|||||||
'core.info.description'
|
'core.info.description'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const aboutCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('about'),
|
||||||
|
'core.about.description'
|
||||||
|
);
|
||||||
|
|
||||||
export const inviteCommandData = applyCommandDescription(
|
export const inviteCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('invite'),
|
new SlashCommandBuilder().setName('invite'),
|
||||||
'core.invite.description'
|
'core.invite.description'
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { buildBotInviteUrl, env } from '../../env.js';
|
|||||||
import {
|
import {
|
||||||
helpCommandData,
|
helpCommandData,
|
||||||
infoCommandData,
|
infoCommandData,
|
||||||
|
aboutCommandData,
|
||||||
inviteCommandData,
|
inviteCommandData,
|
||||||
supportCommandData
|
supportCommandData
|
||||||
} from './command-definitions.js';
|
} from './command-definitions.js';
|
||||||
import { HELP_MODULES } from './help-catalog.js';
|
import { HELP_MODULES } from './help-catalog.js';
|
||||||
|
import { buildAboutReply } from './about.js';
|
||||||
|
|
||||||
const NEXUMI_COLOR = 0x6366f1;
|
const NEXUMI_COLOR = 0x6366f1;
|
||||||
const PACKAGE_VERSION = '0.1.0';
|
const PACKAGE_VERSION = '0.1.0';
|
||||||
@@ -118,6 +120,15 @@ const infoCommand: SlashCommand = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const aboutCommand: SlashCommand = {
|
||||||
|
data: aboutCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const reply = await buildAboutReply(context, locale);
|
||||||
|
await interaction.reply(reply as import('discord.js').InteractionReplyOptions);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const inviteCommand: SlashCommand = {
|
const inviteCommand: SlashCommand = {
|
||||||
data: inviteCommandData,
|
data: inviteCommandData,
|
||||||
async execute(interaction, context) {
|
async execute(interaction, context) {
|
||||||
@@ -153,6 +164,7 @@ const supportCommand: SlashCommand = {
|
|||||||
export const coreCommands: SlashCommand[] = [
|
export const coreCommands: SlashCommand[] = [
|
||||||
helpCommand,
|
helpCommand,
|
||||||
infoCommand,
|
infoCommand,
|
||||||
|
aboutCommand,
|
||||||
inviteCommand,
|
inviteCommand,
|
||||||
supportCommand
|
supportCommand
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** Module id → top-level slash command names for `/help`. */
|
/** Module id → top-level slash command names for `/help`. */
|
||||||
export const HELP_MODULES: ReadonlyArray<{ id: string; commands: readonly string[] }> = [
|
export const HELP_MODULES: ReadonlyArray<{ id: string; commands: readonly string[] }> = [
|
||||||
{ id: 'core', commands: ['help', 'info', 'invite', 'support', 'privacy', 'gdpr'] },
|
{ id: 'core', commands: ['help', 'info', 'about', 'invite', 'support', 'privacy', 'gdpr'] },
|
||||||
{
|
{
|
||||||
id: 'moderation',
|
id: 'moderation',
|
||||||
commands: [
|
commands: [
|
||||||
|
|||||||
36
apps/webui/src/app/api/owner/about/route.ts
Normal file
36
apps/webui/src/app/api/owner/about/route.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { BotAboutConfigPatchSchema } from '@nexumi/shared';
|
||||||
|
import { toApiErrorResponse } from '@/lib/auth';
|
||||||
|
import { getAboutConfig, updateAboutConfig } from '@/lib/owner-about';
|
||||||
|
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||||
|
import { requireOwner } from '@/lib/owner-auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requireOwner('VIEWER');
|
||||||
|
return NextResponse.json(await getAboutConfig());
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await requireOwner('ADMIN');
|
||||||
|
const body = BotAboutConfigPatchSchema.parse(await request.json());
|
||||||
|
const before = await getAboutConfig();
|
||||||
|
const after = await updateAboutConfig(body);
|
||||||
|
await writeOwnerAudit(prisma, {
|
||||||
|
actorUserId: session.user.id,
|
||||||
|
action: 'about.update',
|
||||||
|
targetType: 'BotAboutConfig',
|
||||||
|
targetId: 'singleton',
|
||||||
|
before,
|
||||||
|
after
|
||||||
|
});
|
||||||
|
return NextResponse.json(after);
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
apps/webui/src/app/owner/about/page.tsx
Normal file
21
apps/webui/src/app/owner/about/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { ownerRoleAtLeast } from '@nexumi/shared';
|
||||||
|
import { AboutForm } from '@/components/owner/about-form';
|
||||||
|
import { getLocale, t } from '@/lib/i18n';
|
||||||
|
import { getAboutConfig } from '@/lib/owner-about';
|
||||||
|
import { requireOwnerOrRedirect } from '@/lib/owner-auth';
|
||||||
|
|
||||||
|
export default async function OwnerAboutPage() {
|
||||||
|
const session = await requireOwnerOrRedirect('VIEWER');
|
||||||
|
const [locale, config] = await Promise.all([getLocale(), getAboutConfig()]);
|
||||||
|
const canEdit = ownerRoleAtLeast(session.ownerRole, 'ADMIN');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">{t(locale, 'owner.about.title')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t(locale, 'owner.about.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<AboutForm initialValue={config} canEdit={canEdit} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
apps/webui/src/components/owner/about-form.tsx
Normal file
176
apps/webui/src/components/owner/about-form.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { BotAboutConfig, MessageComponentsV2, WelcomeEmbed } from '@nexumi/shared';
|
||||||
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
|
import { SettingsForm } from '@/components/settings/settings-form';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
DiscordComponentsV2Builder,
|
||||||
|
componentsV2FromUnknown,
|
||||||
|
normalizeComponentsV2
|
||||||
|
} from '@/components/ui/discord-components-v2-builder';
|
||||||
|
import {
|
||||||
|
DiscordEmbedBuilder,
|
||||||
|
embedFromUnknown,
|
||||||
|
normalizeEmbed
|
||||||
|
} from '@/components/ui/discord-embed-builder';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
|
||||||
|
const ABOUT_PREVIEW_VARS = {
|
||||||
|
server: 'Nexumi',
|
||||||
|
'server.icon': 'https://cdn.discordapp.com/embed/avatars/1.png'
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LocalAboutValue {
|
||||||
|
enabled: boolean;
|
||||||
|
messageType: BotAboutConfig['messageType'];
|
||||||
|
content: string | null;
|
||||||
|
embed: WelcomeEmbed | null;
|
||||||
|
components: MessageComponentsV2 | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocal(config: BotAboutConfig): LocalAboutValue {
|
||||||
|
return {
|
||||||
|
enabled: config.enabled,
|
||||||
|
messageType: config.messageType,
|
||||||
|
content: config.content,
|
||||||
|
embed: embedFromUnknown(config.embed),
|
||||||
|
components: componentsV2FromUnknown(config.components)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readError(response: Response): Promise<string> {
|
||||||
|
const data = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||||
|
return data?.error ?? 'Request failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AboutForm({
|
||||||
|
initialValue,
|
||||||
|
canEdit
|
||||||
|
}: {
|
||||||
|
initialValue: BotAboutConfig;
|
||||||
|
canEdit: boolean;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const readOnly = !canEdit;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsForm
|
||||||
|
initialValue={toLocal(initialValue)}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onSave={async (value) => {
|
||||||
|
if (readOnly) {
|
||||||
|
return { ok: false, error: t('owner.about.readOnly') };
|
||||||
|
}
|
||||||
|
const payload: BotAboutConfig = {
|
||||||
|
enabled: value.enabled,
|
||||||
|
messageType: value.messageType,
|
||||||
|
content: value.content,
|
||||||
|
embed: normalizeEmbed(value.embed),
|
||||||
|
components: normalizeComponentsV2(value.components)
|
||||||
|
};
|
||||||
|
const response = await fetch('/api/owner/about', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return { ok: false, error: await readError(response) };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ value, setValue, error }) => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{!canEdit ? (
|
||||||
|
<p className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
{t('owner.about.readOnly')}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('owner.about.editorTitle')}</CardTitle>
|
||||||
|
<CardDescription>{t('owner.about.editorDescription')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||||
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
|
<div className="space-y-1 pr-4">
|
||||||
|
<Label>{t('owner.about.enabled')}</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('owner.about.enabledHint')}</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={value.enabled}
|
||||||
|
disabled={readOnly}
|
||||||
|
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('owner.about.messageType')}</Label>
|
||||||
|
<Select
|
||||||
|
value={value.messageType}
|
||||||
|
disabled={readOnly}
|
||||||
|
onValueChange={(messageType) =>
|
||||||
|
setValue((prev) => ({
|
||||||
|
...prev,
|
||||||
|
messageType: messageType as BotAboutConfig['messageType']
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="TEXT">{t('owner.about.type.TEXT')}</SelectItem>
|
||||||
|
<SelectItem value="EMBED">{t('owner.about.type.EMBED')}</SelectItem>
|
||||||
|
<SelectItem value="COMPONENTS_V2">{t('owner.about.type.COMPONENTS_V2')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{value.messageType === 'TEXT' ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('owner.about.content')}</Label>
|
||||||
|
<Textarea
|
||||||
|
className="min-h-28"
|
||||||
|
disabled={readOnly}
|
||||||
|
value={value.content ?? ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
setValue((prev) => ({ ...prev, content: event.target.value || null }))
|
||||||
|
}
|
||||||
|
placeholder={t('owner.about.contentPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{value.messageType === 'EMBED' ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('owner.about.embed')}</Label>
|
||||||
|
<DiscordEmbedBuilder
|
||||||
|
value={value.embed}
|
||||||
|
onChange={(embed) => setValue((prev) => ({ ...prev, embed }))}
|
||||||
|
previewVars={ABOUT_PREVIEW_VARS}
|
||||||
|
placeholderPreset="welcome"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{value.messageType === 'COMPONENTS_V2' ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('owner.about.components')}</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('owner.about.componentsHint')}</p>
|
||||||
|
<DiscordComponentsV2Builder
|
||||||
|
value={value.components}
|
||||||
|
onChange={(components) => setValue((prev) => ({ ...prev, components }))}
|
||||||
|
previewVars={ABOUT_PREVIEW_VARS}
|
||||||
|
placeholderPreset="welcome"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SettingsForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
BookOpen,
|
BookOpen,
|
||||||
Crown,
|
Crown,
|
||||||
Flag,
|
Flag,
|
||||||
|
Info,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
Radio,
|
Radio,
|
||||||
@@ -26,6 +27,7 @@ export const OWNER_LINKS = [
|
|||||||
{ href: '/owner/flags', icon: Flag, key: 'flags' },
|
{ href: '/owner/flags', icon: Flag, key: 'flags' },
|
||||||
{ href: '/owner/premium', icon: Crown, key: 'premium' },
|
{ href: '/owner/premium', icon: Crown, key: 'premium' },
|
||||||
{ href: '/owner/presence', icon: Radio, key: 'presence' },
|
{ href: '/owner/presence', icon: Radio, key: 'presence' },
|
||||||
|
{ href: '/owner/about', icon: Info, key: 'about' },
|
||||||
{ href: '/owner/team', icon: Users, key: 'team' },
|
{ href: '/owner/team', icon: Users, key: 'team' },
|
||||||
{ href: '/owner/jobs', icon: ListTodo, key: 'jobs' },
|
{ href: '/owner/jobs', icon: ListTodo, key: 'jobs' },
|
||||||
{ href: '/owner/changelog', icon: BookOpen, key: 'changelog' },
|
{ href: '/owner/changelog', icon: BookOpen, key: 'changelog' },
|
||||||
|
|||||||
@@ -940,6 +940,7 @@ export const OWNER_NAV_ENTRIES: SearchIndexEntry[] = [
|
|||||||
{ id: 'owner-flags', kind: 'page', labelKey: 'owner.nav.flags', href: '/owner/flags' },
|
{ id: 'owner-flags', kind: 'page', labelKey: 'owner.nav.flags', href: '/owner/flags' },
|
||||||
{ id: 'owner-premium', kind: 'page', labelKey: 'owner.nav.premium', href: '/owner/premium' },
|
{ id: 'owner-premium', kind: 'page', labelKey: 'owner.nav.premium', href: '/owner/premium' },
|
||||||
{ id: 'owner-presence', kind: 'page', labelKey: 'owner.nav.presence', href: '/owner/presence' },
|
{ id: 'owner-presence', kind: 'page', labelKey: 'owner.nav.presence', href: '/owner/presence' },
|
||||||
|
{ id: 'owner-about', kind: 'page', labelKey: 'owner.nav.about', href: '/owner/about' },
|
||||||
{ id: 'owner-team', kind: 'page', labelKey: 'owner.nav.team', href: '/owner/team' },
|
{ id: 'owner-team', kind: 'page', labelKey: 'owner.nav.team', href: '/owner/team' },
|
||||||
{ id: 'owner-jobs', kind: 'page', labelKey: 'owner.nav.jobs', href: '/owner/jobs' },
|
{ id: 'owner-jobs', kind: 'page', labelKey: 'owner.nav.jobs', href: '/owner/jobs' },
|
||||||
{ id: 'owner-changelog', kind: 'page', labelKey: 'owner.nav.changelog', href: '/owner/changelog' },
|
{ id: 'owner-changelog', kind: 'page', labelKey: 'owner.nav.changelog', href: '/owner/changelog' },
|
||||||
|
|||||||
78
apps/webui/src/lib/owner-about.ts
Normal file
78
apps/webui/src/lib/owner-about.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
ABOUT_REDIS_KEY,
|
||||||
|
BotAboutConfigPatchSchema,
|
||||||
|
BotAboutConfigSchema,
|
||||||
|
MessageComponentsV2Schema,
|
||||||
|
WelcomeEmbedSchema,
|
||||||
|
type BotAboutConfig,
|
||||||
|
type MessageComponentsV2,
|
||||||
|
type WelcomeEmbed
|
||||||
|
} from '@nexumi/shared';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { prisma } from './prisma';
|
||||||
|
import { redis } from './redis';
|
||||||
|
|
||||||
|
function parseEmbed(raw: unknown): WelcomeEmbed | null {
|
||||||
|
const parsed = WelcomeEmbedSchema.safeParse(raw);
|
||||||
|
return parsed.success ? parsed.data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseComponents(raw: unknown): MessageComponentsV2 | null {
|
||||||
|
const parsed = MessageComponentsV2Schema.safeParse(raw);
|
||||||
|
return parsed.success ? parsed.data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRow(row: {
|
||||||
|
enabled: boolean;
|
||||||
|
messageType: string;
|
||||||
|
content: string | null;
|
||||||
|
embed: unknown;
|
||||||
|
components: unknown;
|
||||||
|
}): BotAboutConfig {
|
||||||
|
return BotAboutConfigSchema.parse({
|
||||||
|
enabled: row.enabled,
|
||||||
|
messageType: row.messageType,
|
||||||
|
content: row.content,
|
||||||
|
embed: parseEmbed(row.embed),
|
||||||
|
components: parseComponents(row.components)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAboutConfig(): Promise<BotAboutConfig> {
|
||||||
|
const row = await prisma.botAboutConfig.upsert({
|
||||||
|
where: { id: 'singleton' },
|
||||||
|
create: { id: 'singleton' },
|
||||||
|
update: {}
|
||||||
|
});
|
||||||
|
return mapRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAboutConfig(patch: unknown): Promise<BotAboutConfig> {
|
||||||
|
const data = BotAboutConfigPatchSchema.parse(patch);
|
||||||
|
const current = await getAboutConfig();
|
||||||
|
const next = { ...current, ...data };
|
||||||
|
|
||||||
|
const row = await prisma.botAboutConfig.upsert({
|
||||||
|
where: { id: 'singleton' },
|
||||||
|
create: {
|
||||||
|
id: 'singleton',
|
||||||
|
enabled: next.enabled,
|
||||||
|
messageType: next.messageType,
|
||||||
|
content: next.content,
|
||||||
|
embed: next.embed === null ? Prisma.JsonNull : (next.embed as Prisma.InputJsonValue),
|
||||||
|
components:
|
||||||
|
next.components === null ? Prisma.JsonNull : (next.components as Prisma.InputJsonValue)
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
enabled: next.enabled,
|
||||||
|
messageType: next.messageType,
|
||||||
|
content: next.content,
|
||||||
|
embed: next.embed === null ? Prisma.JsonNull : (next.embed as Prisma.InputJsonValue),
|
||||||
|
components:
|
||||||
|
next.components === null ? Prisma.JsonNull : (next.components as Prisma.InputJsonValue)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const mapped = mapRow(row);
|
||||||
|
await redis.set(ABOUT_REDIS_KEY, JSON.stringify(mapped));
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
@@ -968,6 +968,7 @@
|
|||||||
"flags": "Feature-Flags",
|
"flags": "Feature-Flags",
|
||||||
"premium": "Premium",
|
"premium": "Premium",
|
||||||
"presence": "Präsenz",
|
"presence": "Präsenz",
|
||||||
|
"about": "About",
|
||||||
"team": "Team",
|
"team": "Team",
|
||||||
"jobs": "Jobs",
|
"jobs": "Jobs",
|
||||||
"changelog": "Changelog",
|
"changelog": "Changelog",
|
||||||
@@ -1070,6 +1071,26 @@
|
|||||||
"Competing": "Tritt an in"
|
"Competing": "Tritt an in"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "About /about",
|
||||||
|
"subtitle": "Inhalt der globalen /about-Antwort im Discord.",
|
||||||
|
"editorTitle": "About-Nachricht",
|
||||||
|
"editorDescription": "TEXT, Embed oder Components V2 – erscheint bei /about auf allen Servern.",
|
||||||
|
"enabled": "About aktiv",
|
||||||
|
"enabledHint": "Wenn aus, antwortet /about mit einem Deaktiviert-Hinweis.",
|
||||||
|
"messageType": "Nachrichtentyp",
|
||||||
|
"type": {
|
||||||
|
"TEXT": "Text",
|
||||||
|
"EMBED": "Embed",
|
||||||
|
"COMPONENTS_V2": "Components V2"
|
||||||
|
},
|
||||||
|
"content": "Textinhalt",
|
||||||
|
"contentPlaceholder": "Kurztext über Nexumi…",
|
||||||
|
"embed": "Embed",
|
||||||
|
"components": "Components V2",
|
||||||
|
"componentsHint": "Rollen-Aktionen wirken im Server, in dem /about ausgeführt wird. Ohne Guild-Kontext keine Rollen-Auswahl hier.",
|
||||||
|
"readOnly": "Nur Ansicht – Speichern erfordert die Owner-Rolle Admin oder höher."
|
||||||
|
},
|
||||||
"team": {
|
"team": {
|
||||||
"title": "Team",
|
"title": "Team",
|
||||||
"subtitle": "Weitere Owner/Admins mit abgestuften Rechten.",
|
"subtitle": "Weitere Owner/Admins mit abgestuften Rechten.",
|
||||||
|
|||||||
@@ -968,6 +968,7 @@
|
|||||||
"flags": "Feature flags",
|
"flags": "Feature flags",
|
||||||
"premium": "Premium",
|
"premium": "Premium",
|
||||||
"presence": "Presence",
|
"presence": "Presence",
|
||||||
|
"about": "About",
|
||||||
"team": "Team",
|
"team": "Team",
|
||||||
"jobs": "Jobs",
|
"jobs": "Jobs",
|
||||||
"changelog": "Changelog",
|
"changelog": "Changelog",
|
||||||
@@ -1070,6 +1071,26 @@
|
|||||||
"Competing": "Competing in"
|
"Competing": "Competing in"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "About /about",
|
||||||
|
"subtitle": "Content of the global /about reply in Discord.",
|
||||||
|
"editorTitle": "About message",
|
||||||
|
"editorDescription": "TEXT, embed, or Components V2 — shown for /about on every server.",
|
||||||
|
"enabled": "About enabled",
|
||||||
|
"enabledHint": "When off, /about replies with a disabled notice.",
|
||||||
|
"messageType": "Message type",
|
||||||
|
"type": {
|
||||||
|
"TEXT": "Text",
|
||||||
|
"EMBED": "Embed",
|
||||||
|
"COMPONENTS_V2": "Components V2"
|
||||||
|
},
|
||||||
|
"content": "Text content",
|
||||||
|
"contentPlaceholder": "Short text about Nexumi…",
|
||||||
|
"embed": "Embed",
|
||||||
|
"components": "Components V2",
|
||||||
|
"componentsHint": "Role actions apply in the server where /about is used. No role picker here without a guild context.",
|
||||||
|
"readOnly": "View only — saving requires Admin owner role or higher."
|
||||||
|
},
|
||||||
"team": {
|
"team": {
|
||||||
"title": "Team",
|
"title": "Team",
|
||||||
"subtitle": "Additional owners/admins with graded permissions.",
|
"subtitle": "Additional owners/admins with graded permissions.",
|
||||||
|
|||||||
@@ -350,6 +350,22 @@
|
|||||||
- [ ] Wartung an → Confirm, Discord zeigt Wartungstext, kein Rotating
|
- [ ] Wartung an → Confirm, Discord zeigt Wartungstext, kein Rotating
|
||||||
- [ ] VIEWER sieht Formular, kann nicht speichern
|
- [ ] VIEWER sieht Formular, kann nicht speichern
|
||||||
|
|
||||||
|
## Post-Phase – Owner About /about (Status: implementiert)
|
||||||
|
|
||||||
|
### Abgeschlossen (Code)
|
||||||
|
|
||||||
|
- Core-Command `/about` (neben SPEC-`/info`); Help-Katalog + Locales DE/EN
|
||||||
|
- Prisma `BotAboutConfig` Singleton + Migration `20260724120000_bot_about`
|
||||||
|
- Owner `/owner/about`: TEXT / Embed / Components V2 (Builder), Redis-Cache, Audit
|
||||||
|
- Components-V2-Source `a` für Button-Interaktionen aus `/about`
|
||||||
|
|
||||||
|
### Manuell testen
|
||||||
|
|
||||||
|
- [ ] Migration deploy + Bot/WebUI neu bauen
|
||||||
|
- [ ] Owner About Embed speichern → `/about` zeigt Embed
|
||||||
|
- [ ] About deaktivieren → Deaktiviert-Hinweis
|
||||||
|
- [ ] `/info` unverändert technisch
|
||||||
|
|
||||||
## Post-Phase – Automod/Moderation Dashboard Ausbau (Status: implementiert)
|
## Post-Phase – Automod/Moderation Dashboard Ausbau (Status: implementiert)
|
||||||
|
|
||||||
### Abgeschlossen (Code)
|
### Abgeschlossen (Code)
|
||||||
|
|||||||
@@ -1455,6 +1455,10 @@ export const commandLocales = {
|
|||||||
de: 'Bot-Info: Version, Uptime, Shard, Links',
|
de: 'Bot-Info: Version, Uptime, Shard, Links',
|
||||||
en: 'Bot info: version, uptime, shard, links'
|
en: 'Bot info: version, uptime, shard, links'
|
||||||
},
|
},
|
||||||
|
'core.about.description': {
|
||||||
|
de: 'Über Nexumi',
|
||||||
|
en: 'About Nexumi'
|
||||||
|
},
|
||||||
'core.invite.description': {
|
'core.invite.description': {
|
||||||
de: 'Einladungslink für Nexumi',
|
de: 'Einladungslink für Nexumi',
|
||||||
en: 'Invite link for Nexumi'
|
en: 'Invite link for Nexumi'
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
|||||||
/** Discord MessageFlags.IsComponentsV2 */
|
/** Discord MessageFlags.IsComponentsV2 */
|
||||||
export const COMPONENTS_V2_FLAG = 32768 as const;
|
export const COMPONENTS_V2_FLAG = 32768 as const;
|
||||||
|
|
||||||
export const ComponentV2SourceSchema = z.enum(['w', 'l', 't', 's', 'm']);
|
export const ComponentV2SourceSchema = z.enum(['w', 'l', 't', 's', 'm', 'a']);
|
||||||
export type ComponentV2Source = z.infer<typeof ComponentV2SourceSchema>;
|
export type ComponentV2Source = z.infer<typeof ComponentV2SourceSchema>;
|
||||||
|
|
||||||
export const ComponentActionTypeSchema = z.enum([
|
export const ComponentActionTypeSchema = z.enum([
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ const de: Dictionary = {
|
|||||||
'core.help.subtitle': 'Commands nach Modul. Optional: `/help module:<name>`.',
|
'core.help.subtitle': 'Commands nach Modul. Optional: `/help module:<name>`.',
|
||||||
'core.help.moduleLine': '**{module}**: {commands}',
|
'core.help.moduleLine': '**{module}**: {commands}',
|
||||||
'core.help.unknownModule': 'Unbekanntes Modul. Verfügbar: {modules}',
|
'core.help.unknownModule': 'Unbekanntes Modul. Verfügbar: {modules}',
|
||||||
'core.help.footer': 'Mehr Infos: /info · Dashboard: {webui}',
|
'core.help.footer': 'Mehr Infos: /info · /about · Dashboard: {webui}',
|
||||||
'core.info.title': 'Nexumi',
|
'core.info.title': 'Nexumi',
|
||||||
'core.info.version': 'Version',
|
'core.info.version': 'Version',
|
||||||
'core.info.uptime': 'Uptime',
|
'core.info.uptime': 'Uptime',
|
||||||
@@ -78,6 +78,10 @@ const de: Dictionary = {
|
|||||||
'core.info.link.invite': 'Einladen',
|
'core.info.link.invite': 'Einladen',
|
||||||
'core.info.link.support': 'Support',
|
'core.info.link.support': 'Support',
|
||||||
'core.info.link.status': 'Status',
|
'core.info.link.status': 'Status',
|
||||||
|
'core.about.defaultTitle': 'Über Nexumi',
|
||||||
|
'core.about.defaultBody':
|
||||||
|
'Nexumi ist ein öffentlicher Discord-Bot mit Dashboard. Konfiguriere diese Nachricht im Owner-Panel unter About.',
|
||||||
|
'core.about.disabled': 'Die About-Nachricht ist derzeit deaktiviert.',
|
||||||
'core.invite.title': 'Nexumi einladen',
|
'core.invite.title': 'Nexumi einladen',
|
||||||
'core.invite.body': 'Lade Nexumi mit diesem Link auf deinen Server ein:',
|
'core.invite.body': 'Lade Nexumi mit diesem Link auf deinen Server ein:',
|
||||||
'core.support.title': 'Support',
|
'core.support.title': 'Support',
|
||||||
@@ -658,7 +662,7 @@ const en: Dictionary = {
|
|||||||
'core.help.subtitle': 'Commands by module. Optional: `/help module:<name>`.',
|
'core.help.subtitle': 'Commands by module. Optional: `/help module:<name>`.',
|
||||||
'core.help.moduleLine': '**{module}**: {commands}',
|
'core.help.moduleLine': '**{module}**: {commands}',
|
||||||
'core.help.unknownModule': 'Unknown module. Available: {modules}',
|
'core.help.unknownModule': 'Unknown module. Available: {modules}',
|
||||||
'core.help.footer': 'More: /info · Dashboard: {webui}',
|
'core.help.footer': 'More: /info · /about · Dashboard: {webui}',
|
||||||
'core.info.title': 'Nexumi',
|
'core.info.title': 'Nexumi',
|
||||||
'core.info.version': 'Version',
|
'core.info.version': 'Version',
|
||||||
'core.info.uptime': 'Uptime',
|
'core.info.uptime': 'Uptime',
|
||||||
@@ -670,6 +674,10 @@ const en: Dictionary = {
|
|||||||
'core.info.link.invite': 'Invite',
|
'core.info.link.invite': 'Invite',
|
||||||
'core.info.link.support': 'Support',
|
'core.info.link.support': 'Support',
|
||||||
'core.info.link.status': 'Status',
|
'core.info.link.status': 'Status',
|
||||||
|
'core.about.defaultTitle': 'About Nexumi',
|
||||||
|
'core.about.defaultBody':
|
||||||
|
'Nexumi is a public Discord bot with a dashboard. Configure this message in the Owner panel under About.',
|
||||||
|
'core.about.disabled': 'The about message is currently disabled.',
|
||||||
'core.invite.title': 'Invite Nexumi',
|
'core.invite.title': 'Invite Nexumi',
|
||||||
'core.invite.body': 'Invite Nexumi to your server with this link:',
|
'core.invite.body': 'Invite Nexumi to your server with this link:',
|
||||||
'core.support.title': 'Support',
|
'core.support.title': 'Support',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
BotAboutConfigSchema,
|
||||||
BotPresenceConfigSchema,
|
BotPresenceConfigSchema,
|
||||||
FeatureFlagUpsertSchema,
|
FeatureFlagUpsertSchema,
|
||||||
ownerRoleAtLeast,
|
ownerRoleAtLeast,
|
||||||
@@ -19,6 +20,17 @@ describe('owner schemas', () => {
|
|||||||
expect(parsed.activityText).toBe('nexumi.de');
|
expect(parsed.activityText).toBe('nexumi.de');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('parses about config', () => {
|
||||||
|
const parsed = BotAboutConfigSchema.parse({
|
||||||
|
enabled: true,
|
||||||
|
messageType: 'EMBED',
|
||||||
|
content: null,
|
||||||
|
embed: { title: 'About', description: 'Nexumi', color: 0x6366f1 },
|
||||||
|
components: null
|
||||||
|
});
|
||||||
|
expect(parsed.embed?.title).toBe('About');
|
||||||
|
});
|
||||||
|
|
||||||
it('ranks owner roles', () => {
|
it('ranks owner roles', () => {
|
||||||
expect(ownerRoleAtLeast('ADMIN', 'SUPPORT')).toBe(true);
|
expect(ownerRoleAtLeast('ADMIN', 'SUPPORT')).toBe(true);
|
||||||
expect(ownerRoleAtLeast('VIEWER', 'ADMIN')).toBe(false);
|
expect(ownerRoleAtLeast('VIEWER', 'ADMIN')).toBe(false);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { MessageComponentsV2Schema } from './components-v2.js';
|
||||||
|
import { WelcomeEmbedSchema } from './phase2.js';
|
||||||
|
|
||||||
const snowflake = z.string().regex(/^\d{17,20}$/);
|
const snowflake = z.string().regex(/^\d{17,20}$/);
|
||||||
|
|
||||||
@@ -114,3 +116,26 @@ export const OwnerAuditEntrySchema = z.object({
|
|||||||
export type OwnerAuditEntry = z.infer<typeof OwnerAuditEntrySchema>;
|
export type OwnerAuditEntry = z.infer<typeof OwnerAuditEntrySchema>;
|
||||||
|
|
||||||
export const PRESENCE_REDIS_KEY = 'bot:presence';
|
export const PRESENCE_REDIS_KEY = 'bot:presence';
|
||||||
|
|
||||||
|
export const BotAboutMessageTypeSchema = z.enum(['TEXT', 'EMBED', 'COMPONENTS_V2']);
|
||||||
|
export type BotAboutMessageType = z.infer<typeof BotAboutMessageTypeSchema>;
|
||||||
|
|
||||||
|
/** Global `/about` reply configured in the Owner panel. */
|
||||||
|
export const BotAboutConfigSchema = z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
messageType: BotAboutMessageTypeSchema,
|
||||||
|
content: z.string().max(2000).nullable(),
|
||||||
|
embed: WelcomeEmbedSchema.nullable(),
|
||||||
|
components: MessageComponentsV2Schema.nullable()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BotAboutConfig = z.infer<typeof BotAboutConfigSchema>;
|
||||||
|
|
||||||
|
export const BotAboutConfigPatchSchema = BotAboutConfigSchema.partial().refine(
|
||||||
|
(value) => Object.keys(value).length > 0,
|
||||||
|
{ message: 'At least one field is required' }
|
||||||
|
);
|
||||||
|
|
||||||
|
export type BotAboutConfigPatch = z.infer<typeof BotAboutConfigPatchSchema>;
|
||||||
|
|
||||||
|
export const ABOUT_REDIS_KEY = 'bot:about';
|
||||||
|
|||||||
Reference in New Issue
Block a user