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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
24
apps/bot/src/modules/core/command-definitions.ts
Normal file
24
apps/bot/src/modules/core/command-definitions.ts
Normal 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'
|
||||
);
|
||||
158
apps/bot/src/modules/core/commands.ts
Normal file
158
apps/bot/src/modules/core/commands.ts
Normal 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
|
||||
];
|
||||
93
apps/bot/src/modules/core/help-catalog.ts
Normal file
93
apps/bot/src/modules/core/help-catalog.ts
Normal 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'] }
|
||||
];
|
||||
2
apps/bot/src/modules/core/index.ts
Normal file
2
apps/bot/src/modules/core/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { coreCommands } from './commands.js';
|
||||
export { HELP_MODULES } from './help-catalog.js';
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user