Add global /about command and configuration

- Introduced `BotAboutConfig` model for managing the global `/about` message in the owner panel.
- Implemented the `/about` command with localization support and integrated it into the help catalog.
- Enhanced the dashboard with a new owner section for configuring the `/about` message type and content.
- Updated components to handle the new command and its interactions, including support for different message formats (TEXT, EMBED, COMPONENTS_V2).
- Added localization entries for the `/about` command in English and German, improving accessibility for users.
- Documented the implementation in the phase tracking documentation for future reference.
This commit is contained in:
smueller
2026-07-24 12:26:00 +02:00
parent 20593b7173
commit c7fb3d7041
22 changed files with 572 additions and 4 deletions

View File

@@ -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")
);

View File

@@ -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

View File

@@ -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();
});
});

View File

@@ -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;
}

View 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);
}

View File

@@ -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'

View File

@@ -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
];

View File

@@ -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: [