Add stats, feeds, scheduler, and guild backup features to the bot

- Introduced new models in the Prisma schema for statistics, social feeds, scheduled messages, and guild backups, enhancing the bot's functionality.
- Implemented command modules for managing stats channels, feeds, scheduling messages, and creating/restoring backups, improving user engagement and server management.
- Updated job handling to include dedicated workers for stats updates, feed polling, scheduling, and backup restoration, enhancing automation.
- Enhanced command localization for new features in both German and English.
- Documented the new features and their setup processes in PHASE-TRACKING.md for clarity on implementation steps.
This commit is contained in:
smueller
2026-07-22 13:36:39 +02:00
parent 52b10c9536
commit 12066befa8
36 changed files with 4032 additions and 42 deletions

View File

@@ -0,0 +1,203 @@
import { PermissionFlagsBits } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { backupCommandData } from './command-definitions.js';
import {
createGuildBackup,
ensureBotCanRestore,
getGuildBackup,
GuildBackupError,
listGuildBackups,
parseBackupPayload
} from './service.js';
import { promptDeleteConfirmation, promptRestoreConfirmation } from './confirmations.js';
async function ensureManageGuild(
interaction: Parameters<SlashCommand['execute']>[0],
locale: 'de' | 'en'
): Promise<boolean> {
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return false;
}
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
}
function defaultBackupName(): string {
return `backup-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`;
}
const backupCommand: SlashCommand = {
data: backupCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const sub = interaction.options.getSubcommand();
const guild = interaction.guild!;
if (sub === 'create') {
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
const name = interaction.options.getString('name')?.trim() || defaultBackupName();
if (name.length < 1 || name.length > 100) {
await interaction.reply({
content: t(locale, 'guildbackup.error.invalid_name'),
ephemeral: true
});
return;
}
try {
const backup = await createGuildBackup(context, guild, name, interaction.user.id);
await interaction.reply({
content: tf(locale, 'guildbackup.created', { id: backup.id, name: backup.name }),
ephemeral: true
});
} catch (error) {
if (error instanceof GuildBackupError) {
await interaction.reply({
content: t(locale, `guildbackup.error.${error.code}`),
ephemeral: true
});
return;
}
throw error;
}
return;
}
if (sub === 'list') {
const backups = await listGuildBackups(context, guild.id);
if (backups.length === 0) {
await interaction.reply({
content: t(locale, 'guildbackup.list.empty'),
ephemeral: true
});
return;
}
const lines = backups.map((backup) =>
tf(locale, 'guildbackup.list.line', {
id: backup.id,
name: backup.name,
date: backup.createdAt.toISOString().slice(0, 10)
})
);
await interaction.reply({
content: `${t(locale, 'guildbackup.list.header')}\n${lines.join('\n')}`,
ephemeral: true
});
return;
}
if (sub === 'info') {
const backupId = interaction.options.getString('id', true);
const backup = await getGuildBackup(context, guild.id, backupId);
if (!backup) {
await interaction.reply({
content: t(locale, 'guildbackup.error.not_found'),
ephemeral: true
});
return;
}
let roleCount = 0;
let channelCount = 0;
try {
const payload = parseBackupPayload(backup.payload);
roleCount = payload.roles.length;
channelCount = payload.channels.length;
} catch {
await interaction.reply({
content: t(locale, 'guildbackup.error.invalid_payload'),
ephemeral: true
});
return;
}
await interaction.reply({
content: [
tf(locale, 'guildbackup.info.id', { id: backup.id }),
tf(locale, 'guildbackup.info.name', { name: backup.name }),
tf(locale, 'guildbackup.info.created', { date: backup.createdAt.toISOString() }),
tf(locale, 'guildbackup.info.createdBy', { userId: backup.createdById }),
tf(locale, 'guildbackup.info.roles', { count: roleCount }),
tf(locale, 'guildbackup.info.channels', { count: channelCount }),
t(locale, 'guildbackup.info.limitations')
].join('\n'),
ephemeral: true
});
return;
}
if (sub === 'delete') {
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
const backupId = interaction.options.getString('id', true);
const backup = await getGuildBackup(context, guild.id, backupId);
if (!backup) {
await interaction.reply({
content: t(locale, 'guildbackup.error.not_found'),
ephemeral: true
});
return;
}
await promptDeleteConfirmation(interaction, locale, backupId);
return;
}
if (sub === 'restore') {
if (guild.ownerId !== interaction.user.id) {
await interaction.reply({
content: t(locale, 'guildbackup.error.owner_only'),
ephemeral: true
});
return;
}
if (!ensureBotCanRestore(guild)) {
await interaction.reply({
content: t(locale, 'guildbackup.error.bot_missing_permissions'),
ephemeral: true
});
return;
}
const backupId = interaction.options.getString('id', true);
const backup = await getGuildBackup(context, guild.id, backupId);
if (!backup) {
await interaction.reply({
content: t(locale, 'guildbackup.error.not_found'),
ephemeral: true
});
return;
}
try {
parseBackupPayload(backup.payload);
} catch {
await interaction.reply({
content: t(locale, 'guildbackup.error.invalid_payload'),
ephemeral: true
});
return;
}
await promptRestoreConfirmation(interaction, locale, backupId);
}
}
};
export const guildBackupCommands = [backupCommand];