Enhance bot and WebUI functionality with new features and environment updates

- Added support for new environment variables in `.env.example` for public site and legal operator information.
- Integrated core commands into the bot's command structure for improved functionality.
- Implemented a new `publishBotStatus` function to update bot status in Redis, enhancing monitoring capabilities.
- Updated the landing page to include features, invite links, and support server access, improving user experience.
- Enhanced localization with new keys for core commands and landing page elements in both English and German.
- Improved error handling and logging for bot presence updates and status publishing.
This commit is contained in:
smueller
2026-07-22 16:47:09 +02:00
parent 0b4ac0da29
commit 4852f16f79
32 changed files with 1579 additions and 83 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
import { SlashCommandBuilder } from 'discord.js';
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
export const helpCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('help'),
'core.help.description'
).addStringOption((option) =>
applyOptionDescription(option.setName('module'), 'core.help.options.module')
);
export const infoCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('info'),
'core.info.description'
);
export const inviteCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('invite'),
'core.invite.description'
);
export const supportCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('support'),
'core.support.description'
);

View File

@@ -0,0 +1,158 @@
import { EmbedBuilder } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { buildBotInviteUrl, env } from '../../env.js';
import {
helpCommandData,
infoCommandData,
inviteCommandData,
supportCommandData
} from './command-definitions.js';
import { HELP_MODULES } from './help-catalog.js';
const NEXUMI_COLOR = 0x6366f1;
const PACKAGE_VERSION = '0.1.0';
function formatUptime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return `${h}h ${m}m ${s}s`;
}
function webuiBase(): string {
return env.WEBUI_URL ?? env.PUBLIC_BASE_URL;
}
const helpCommand: SlashCommand = {
data: helpCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const filter = interaction.options.getString('module')?.trim().toLowerCase() ?? null;
const modules = filter
? HELP_MODULES.filter((module) => module.id === filter)
: HELP_MODULES;
if (modules.length === 0) {
await interaction.reply({
content: tf(locale, 'core.help.unknownModule', {
modules: HELP_MODULES.map((module) => module.id).join(', ')
}),
ephemeral: true
});
return;
}
const lines = modules.map((module) =>
tf(locale, 'core.help.moduleLine', {
module: t(locale, `core.module.${module.id}`),
commands: module.commands.map((name) => `\`/${name}\``).join(', ')
})
);
// Discord embed description limit is 4096; split across fields if needed.
const embed = new EmbedBuilder()
.setTitle(t(locale, 'core.help.title'))
.setDescription(t(locale, 'core.help.subtitle'))
.setColor(NEXUMI_COLOR)
.setFooter({
text: tf(locale, 'core.help.footer', { webui: webuiBase() })
});
const chunkSize = 8;
for (let i = 0; i < lines.length; i += chunkSize) {
const chunk = lines.slice(i, i + chunkSize);
embed.addFields({
name: '\u200b',
value: chunk.join('\n')
});
}
await interaction.reply({ embeds: [embed], ephemeral: true });
}
};
const infoCommand: SlashCommand = {
data: infoCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const shardId = context.client.shard?.ids[0] ?? 0;
const inviteUrl = buildBotInviteUrl();
const base = webuiBase();
const linkLines = [
`[${t(locale, 'core.info.link.dashboard')}](${base})`,
`[${t(locale, 'core.info.link.invite')}](${inviteUrl})`,
`[${t(locale, 'core.info.link.status')}](${base.replace(/\/$/, '')}/status)`
];
if (env.SUPPORT_SERVER_URL) {
linkLines.push(`[${t(locale, 'core.info.link.support')}](${env.SUPPORT_SERVER_URL})`);
}
const embed = new EmbedBuilder()
.setTitle(t(locale, 'core.info.title'))
.setColor(NEXUMI_COLOR)
.addFields(
{ name: t(locale, 'core.info.version'), value: PACKAGE_VERSION, inline: true },
{
name: t(locale, 'core.info.uptime'),
value: formatUptime(process.uptime()),
inline: true
},
{ name: t(locale, 'core.info.shard'), value: String(shardId), inline: true },
{
name: t(locale, 'core.info.guilds'),
value: String(context.client.guilds.cache.size),
inline: true
},
{
name: t(locale, 'core.info.latency'),
value: `${context.client.ws.ping} ms`,
inline: true
},
{ name: t(locale, 'core.info.links'), value: linkLines.join('\n') }
)
.setFooter({ text: 'Nexumi' });
await interaction.reply({ embeds: [embed], ephemeral: true });
}
};
const inviteCommand: SlashCommand = {
data: inviteCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const inviteUrl = buildBotInviteUrl();
const embed = new EmbedBuilder()
.setTitle(t(locale, 'core.invite.title'))
.setDescription(`${t(locale, 'core.invite.body')}\n${inviteUrl}`)
.setColor(NEXUMI_COLOR);
await interaction.reply({ embeds: [embed], ephemeral: true });
}
};
const supportCommand: SlashCommand = {
data: supportCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!env.SUPPORT_SERVER_URL) {
await interaction.reply({
content: t(locale, 'core.support.missing'),
ephemeral: true
});
return;
}
const embed = new EmbedBuilder()
.setTitle(t(locale, 'core.support.title'))
.setDescription(`${t(locale, 'core.support.body')}\n${env.SUPPORT_SERVER_URL}`)
.setColor(NEXUMI_COLOR);
await interaction.reply({ embeds: [embed], ephemeral: true });
}
};
export const coreCommands: SlashCommand[] = [
helpCommand,
infoCommand,
inviteCommand,
supportCommand
];

View File

@@ -0,0 +1,93 @@
/** Module id → top-level slash command names for `/help`. */
export const HELP_MODULES: ReadonlyArray<{ id: string; commands: readonly string[] }> = [
{ id: 'core', commands: ['help', 'info', 'invite', 'support'] },
{
id: 'moderation',
commands: [
'ban',
'unban',
'kick',
'timeout',
'untimeout',
'warn',
'purge',
'slowmode',
'lock',
'unlock',
'nick',
'case',
'modnote'
]
},
{ id: 'automod', commands: ['automod'] },
{ id: 'welcome', commands: ['welcome'] },
{ id: 'verification', commands: ['verify'] },
{ id: 'leveling', commands: ['rank', 'leaderboard', 'xp'] },
{
id: 'economy',
commands: [
'balance',
'daily',
'weekly',
'work',
'pay',
'gamble',
'slots',
'blackjack',
'coinflip',
'shop',
'inventory',
'eco'
]
},
{
id: 'utility',
commands: [
'userinfo',
'serverinfo',
'roleinfo',
'channelinfo',
'avatar',
'banner',
'poll',
'remindme',
'reminders',
'afk',
'emoji',
'sticker',
'timestamp',
'snipe',
'editsnipe',
'embed'
]
},
{
id: 'fun',
commands: [
'8ball',
'dice',
'flip',
'rps',
'choose',
'trivia',
'tictactoe',
'connect4',
'hangman',
'meme',
'cat',
'dog'
]
},
{ id: 'giveaways', commands: ['giveaway'] },
{ id: 'tickets', commands: ['ticket'] },
{ id: 'selfroles', commands: ['selfroles'] },
{ id: 'tags', commands: ['tag'] },
{ id: 'starboard', commands: ['starboard'] },
{ id: 'suggestions', commands: ['suggest', 'suggestion'] },
{ id: 'birthdays', commands: ['birthday'] },
{ id: 'tempvoice', commands: ['voice'] },
{ id: 'stats', commands: ['stats', 'invites'] },
{ id: 'feeds', commands: ['feeds'] },
{ id: 'scheduler', commands: ['schedule', 'announce'] },
{ id: 'guildbackup', commands: ['backup'] }
];

View File

@@ -0,0 +1,2 @@
export { coreCommands } from './commands.js';
export { HELP_MODULES } from './help-catalog.js';

View File

@@ -1,9 +1,87 @@
import { ActivityType } from 'discord.js';
import { PRESENCE_REDIS_KEY, BotPresenceConfigSchema, type BotPresenceConfig } from '@nexumi/shared';
import { ActivityType, Status } from 'discord.js';
import {
BOT_STATUS_REDIS_KEY,
PRESENCE_REDIS_KEY,
BotPresenceConfigSchema,
BotStatusPayloadSchema,
type BotPresenceConfig
} from '@nexumi/shared';
import { redis } from './redis.js';
import { logger } from './logger.js';
import type { BotContext } from './types.js';
const BOT_STATUS_SHARDS_HASH = 'bot:status:shards';
const BOT_STATUS_TTL_SECONDS = 120;
function shardStatusLabel(wsStatus: number): string {
switch (wsStatus) {
case Status.Ready:
return 'ready';
case Status.Connecting:
return 'connecting';
case Status.Reconnecting:
return 'reconnecting';
case Status.Idle:
return 'idle';
case Status.Nearly:
return 'nearly';
case Status.Disconnected:
return 'disconnected';
case Status.WaitingForGuilds:
return 'waiting_for_guilds';
case Status.Identifying:
return 'identifying';
case Status.Resuming:
return 'resuming';
default:
return 'unknown';
}
}
export async function publishBotStatus(context: BotContext): Promise<void> {
const shardId = context.client.shard?.ids[0] ?? 0;
const shardEntry = {
id: shardId,
status: shardStatusLabel(context.client.ws.status),
guildCount: context.client.guilds.cache.size,
ping: context.client.ws.ping
};
await redis.hset(BOT_STATUS_SHARDS_HASH, String(shardId), JSON.stringify(shardEntry));
await redis.expire(BOT_STATUS_SHARDS_HASH, BOT_STATUS_TTL_SECONDS);
const all = await redis.hgetall(BOT_STATUS_SHARDS_HASH);
const shards = Object.values(all)
.map((raw) => {
try {
return JSON.parse(raw) as {
id: number;
status: string;
guildCount: number;
ping: number;
};
} catch {
return null;
}
})
.filter((entry): entry is NonNullable<typeof entry> => entry !== null)
.sort((a, b) => a.id - b.id);
if (shards.length === 0) {
return;
}
const payload = BotStatusPayloadSchema.parse({
shards,
apiLatencyMs: Math.round(shards.reduce((sum, shard) => sum + shard.ping, 0) / shards.length),
guildCount: shards.reduce((sum, shard) => sum + shard.guildCount, 0),
uptimeSeconds: Math.floor(process.uptime()),
updatedAt: new Date().toISOString()
});
await redis.set(BOT_STATUS_REDIS_KEY, JSON.stringify(payload), 'EX', BOT_STATUS_TTL_SECONDS);
}
const ACTIVITY_TYPE_MAP: Record<BotPresenceConfig['activityType'], ActivityType> = {
Playing: ActivityType.Playing,
Listening: ActivityType.Listening,
@@ -75,4 +153,9 @@ export async function runPresenceRefreshJob(context: BotContext): Promise<void>
} catch (error) {
logger.warn({ error }, 'Failed to apply bot presence');
}
try {
await publishBotStatus(context);
} catch (error) {
logger.warn({ error }, 'Failed to publish bot status');
}
}