diff --git a/apps/bot/prisma/migrations/20260724120000_bot_about/migration.sql b/apps/bot/prisma/migrations/20260724120000_bot_about/migration.sql new file mode 100644 index 0000000..703669f --- /dev/null +++ b/apps/bot/prisma/migrations/20260724120000_bot_about/migration.sql @@ -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") +); diff --git a/apps/bot/prisma/schema.prisma b/apps/bot/prisma/schema.prisma index 1e15fe8..73a5f1d 100644 --- a/apps/bot/prisma/schema.prisma +++ b/apps/bot/prisma/schema.prisma @@ -914,6 +914,18 @@ model BotPresenceConfig { 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 { id String @id @default(cuid()) version String diff --git a/apps/bot/src/module-gates.test.ts b/apps/bot/src/module-gates.test.ts index d68729f..8d1ea0d 100644 --- a/apps/bot/src/module-gates.test.ts +++ b/apps/bot/src/module-gates.test.ts @@ -20,6 +20,7 @@ describe('moduleForCommand', () => { it('leaves core commands unmapped', () => { expect(moduleForCommand('help')).toBeNull(); expect(moduleForCommand('info')).toBeNull(); + expect(moduleForCommand('about')).toBeNull(); expect(moduleForCommand('gdpr')).toBeNull(); }); }); diff --git a/apps/bot/src/modules/components-v2/interactions.ts b/apps/bot/src/modules/components-v2/interactions.ts index 6df750d..21f65ca 100644 --- a/apps/bot/src/modules/components-v2/interactions.ts +++ b/apps/bot/src/modules/components-v2/interactions.ts @@ -67,6 +67,13 @@ async function loadPayload( } 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: return null; } diff --git a/apps/bot/src/modules/core/about.ts b/apps/bot/src/modules/core/about.ts new file mode 100644 index 0000000..7eaefdc --- /dev/null +++ b/apps/bot/src/modules/core/about.ts @@ -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 { + 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 { + 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); +} diff --git a/apps/bot/src/modules/core/command-definitions.ts b/apps/bot/src/modules/core/command-definitions.ts index 7901d59..b98ad98 100644 --- a/apps/bot/src/modules/core/command-definitions.ts +++ b/apps/bot/src/modules/core/command-definitions.ts @@ -13,6 +13,11 @@ export const infoCommandData = applyCommandDescription( 'core.info.description' ); +export const aboutCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('about'), + 'core.about.description' +); + export const inviteCommandData = applyCommandDescription( new SlashCommandBuilder().setName('invite'), 'core.invite.description' diff --git a/apps/bot/src/modules/core/commands.ts b/apps/bot/src/modules/core/commands.ts index 3bd9e52..20ebe49 100644 --- a/apps/bot/src/modules/core/commands.ts +++ b/apps/bot/src/modules/core/commands.ts @@ -6,10 +6,12 @@ import { buildBotInviteUrl, env } from '../../env.js'; import { helpCommandData, infoCommandData, + aboutCommandData, inviteCommandData, supportCommandData } from './command-definitions.js'; import { HELP_MODULES } from './help-catalog.js'; +import { buildAboutReply } from './about.js'; const NEXUMI_COLOR = 0x6366f1; 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 = { data: inviteCommandData, async execute(interaction, context) { @@ -153,6 +164,7 @@ const supportCommand: SlashCommand = { export const coreCommands: SlashCommand[] = [ helpCommand, infoCommand, + aboutCommand, inviteCommand, supportCommand ]; diff --git a/apps/bot/src/modules/core/help-catalog.ts b/apps/bot/src/modules/core/help-catalog.ts index 85bf0cb..17e1b0a 100644 --- a/apps/bot/src/modules/core/help-catalog.ts +++ b/apps/bot/src/modules/core/help-catalog.ts @@ -1,6 +1,6 @@ /** 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', 'privacy', 'gdpr'] }, + { id: 'core', commands: ['help', 'info', 'about', 'invite', 'support', 'privacy', 'gdpr'] }, { id: 'moderation', commands: [ diff --git a/apps/webui/src/app/api/owner/about/route.ts b/apps/webui/src/app/api/owner/about/route.ts new file mode 100644 index 0000000..ea7f230 --- /dev/null +++ b/apps/webui/src/app/api/owner/about/route.ts @@ -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); + } +} diff --git a/apps/webui/src/app/owner/about/page.tsx b/apps/webui/src/app/owner/about/page.tsx new file mode 100644 index 0000000..6bb521b --- /dev/null +++ b/apps/webui/src/app/owner/about/page.tsx @@ -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 ( +
+
+

{t(locale, 'owner.about.title')}

+

{t(locale, 'owner.about.subtitle')}

+
+ +
+ ); +} diff --git a/apps/webui/src/components/owner/about-form.tsx b/apps/webui/src/components/owner/about-form.tsx new file mode 100644 index 0000000..adfec8d --- /dev/null +++ b/apps/webui/src/components/owner/about-form.tsx @@ -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 { + 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 ( + { + 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 }) => ( +
+ {!canEdit ? ( +

+ {t('owner.about.readOnly')} +

+ ) : null} + + + {t('owner.about.editorTitle')} + {t('owner.about.editorDescription')} + + + {error ?

{error}

: null} +
+
+ +

{t('owner.about.enabledHint')}

+
+ setValue((prev) => ({ ...prev, enabled: checked }))} + /> +
+
+ + +
+ {value.messageType === 'TEXT' ? ( +
+ +