Implement premium features and GDPR compliance in bot and WebUI

- Added new models for premium tier configurations and assignments, enabling feature limits for free and premium users.
- Introduced GDPR deletion logging and commands for user data management.
- Enhanced logging configuration with retention days for audit logs.
- Updated bot commands and job processing to include new premium and GDPR functionalities.
- Improved localization with new keys for premium features and GDPR commands in both English and German.
- Added UI components for managing premium settings and logging retention in the WebUI.
This commit is contained in:
smueller
2026-07-22 16:59:23 +02:00
parent 4852f16f79
commit 08af99ed52
42 changed files with 1546 additions and 30 deletions

View File

@@ -1418,6 +1418,22 @@ export const commandLocales = {
'core.support.description': {
de: 'Link zum Support-Server',
en: 'Link to the support server'
},
'gdpr.privacy.description': {
de: 'Datenschutzhinweis und Link zur Datenschutzerklärung',
en: 'Privacy notice and link to the privacy policy'
},
'gdpr.command.description': {
de: 'DSGVO-Datenanfragen',
en: 'GDPR data requests'
},
'gdpr.delete.description': {
de: 'Persönliche Bot-Daten löschen / anonymisieren',
en: 'Delete / anonymize your personal bot data'
},
'gdpr.delete.options.confirm': {
de: 'Auf true setzen zur Bestätigung',
en: 'Set to true to confirm'
}
} as const;

View File

@@ -162,6 +162,7 @@ export const LoggingConfigDashboardSchema = z.object({
ignoreBots: z.boolean(),
ignoredChannelIds: z.array(z.string()),
ignoredRoleIds: z.array(z.string()),
retentionDays: z.number().int().min(0).max(3650),
logChannels: z.array(LogChannelMappingSchema)
});
export type LoggingConfigDashboard = z.infer<typeof LoggingConfigDashboardSchema>;
@@ -172,6 +173,7 @@ export const LoggingConfigDashboardPatchSchema = z
ignoreBots: z.boolean().optional(),
ignoredChannelIds: z.array(z.string()).optional(),
ignoredRoleIds: z.array(z.string()).optional(),
retentionDays: z.number().int().min(0).max(3650).optional(),
logChannels: z.array(LogChannelMappingSchema).optional()
})
.refine((value) => Object.keys(value).length > 0, { message: 'At least one field is required' });
@@ -632,6 +634,7 @@ export const KNOWN_COMMAND_NAMES = [
'reminders', 'afk', 'emoji', 'sticker', 'timestamp', 'snipe', 'editsnipe', 'embed', '8ball', 'dice',
'flip', 'rps', 'choose', 'trivia', 'tictactoe', 'connect4', 'hangman', 'meme', 'cat', 'dog',
'giveaway', 'ticket', 'selfroles', 'tag', 'starboard', 'suggest', 'suggestion', 'birthday', 'voice',
'stats', 'invites', 'feeds', 'schedule', 'announce', 'backup'
'stats', 'invites', 'feeds', 'schedule', 'announce', 'backup', 'help', 'info', 'invite', 'support',
'privacy', 'gdpr'
] as const;
export type KnownCommandName = (typeof KNOWN_COMMAND_NAMES)[number];

View File

@@ -11,6 +11,7 @@ export * from './phase6.js';
export * from './dashboard.js';
export * from './owner.js';
export * from './public.js';
export * from './premium.js';
export const LocaleSchema = z.enum(['de', 'en']);
export type Locale = z.infer<typeof LocaleSchema>;
@@ -95,6 +96,15 @@ const de: Dictionary = {
'core.module.feeds': 'Feeds',
'core.module.scheduler': 'Scheduler',
'core.module.guildbackup': 'Backup',
'gdpr.privacy.title': 'Datenschutz',
'gdpr.privacy.body':
'Nexumi speichert Discord-IDs und modulbezogene Daten (z. B. Verwarnungen, XP, Economy), soweit für den Betrieb nötig.',
'gdpr.privacy.link': 'Vollständige Datenschutzerklärung: {url}',
'gdpr.privacy.deleteHint': 'Persönliche Daten löschen: `/gdpr delete confirm:true`',
'gdpr.delete.needConfirm': 'Setze `confirm` auf `true`, um die Löschung zu bestätigen.',
'gdpr.delete.doneTitle': 'Daten gelöscht',
'gdpr.delete.doneBody':
'Gelöscht/anonymisiert: {warnings} Verwarnungen, {levels} Level-Einträge, {economy} Economy-Einträge, {cases} Cases anonymisiert.',
'moderation.success': 'Aktion erfolgreich ausgeführt.',
'moderation.reason.none': 'Kein Grund angegeben',
'moderation.warning.created': 'Verwarnung erstellt: {warningId}',
@@ -239,6 +249,7 @@ const de: Dictionary = {
'utility.embed.sent': 'Embed gesendet.',
'utility.embed.invalidColor': 'Ungültige Farbe. Nutze z. B. 6366F1.',
'utility.snipe.none': 'Nichts zum Anzeigen.',
'utility.snipe.disabled': 'Snipe ist auf diesem Server deaktiviert (Datenschutz).',
'utility.snipe.privacy':
'Datenschutz: Snipe speichert gelöschte/bearbeitete Nachrichten kurzzeitig (1 h) in Redis.',
'utility.snipe.deletedAt': 'Gelöschte Nachricht von {user}',
@@ -498,6 +509,8 @@ const de: Dictionary = {
'tag.error.invalid_name': 'Ungültiger Tag-Name (132 Zeichen, a-z, 0-9, _, -).',
'tag.error.content_required': 'Text-Tags benötigen Inhalt.',
'tag.error.embed_required': 'Embed-Tags benötigen Titel oder Beschreibung.',
'tag.error.premium_limit': 'Tag-Limit für diese Premium-Stufe erreicht.',
'tag.error.premium_feature': 'Tags sind für diese Premium-Stufe deaktiviert.',
'guildbackup.description': 'Server-Backups erstellen und wiederherstellen',
'guildbackup.created': 'Backup **{name}** erstellt (ID: `{id}`).',
'guildbackup.deleted': 'Backup `{id}` gelöscht.',
@@ -532,6 +545,8 @@ const de: Dictionary = {
'guildbackup.error.owner_only': 'Nur der Server-Owner kann Backups wiederherstellen.',
'guildbackup.error.bot_missing_permissions':
'Dem Bot fehlen Manage Roles oder Manage Channels für einen Restore.',
'guildbackup.error.premium_limit': 'Backup-Limit für diese Premium-Stufe erreicht.',
'guildbackup.error.premium_feature': 'Backups sind für diese Premium-Stufe deaktiviert.',
'feeds.added': 'Feed `{id}` ({type}) hinzugefügt.',
'feeds.removed': 'Feed `{id}` entfernt.',
'feeds.list.header': '**Social Feeds**',
@@ -544,6 +559,8 @@ const de: Dictionary = {
'feeds.test.twitchSkipped':
'Twitch-Feed übersprungen: TWITCH_CLIENT_ID und TWITCH_CLIENT_SECRET fehlen in der Bot-Konfiguration.',
'feeds.error.not_found': 'Feed nicht gefunden.',
'feeds.error.premium_limit': 'Feed-Limit für diese Premium-Stufe erreicht.',
'feeds.error.premium_feature': 'Feeds sind für diese Premium-Stufe deaktiviert.',
'scheduler.created': 'Geplante Nachricht erstellt (ID: `{id}`). {timing}',
'scheduler.created.at': 'Sendung: {time}',
'scheduler.created.cron': 'Wiederholung: `{cron}`',
@@ -612,6 +629,15 @@ const en: Dictionary = {
'core.module.feeds': 'Feeds',
'core.module.scheduler': 'Scheduler',
'core.module.guildbackup': 'Backup',
'gdpr.privacy.title': 'Privacy',
'gdpr.privacy.body':
'Nexumi stores Discord IDs and module-related data (e.g. warnings, XP, economy) as needed to operate.',
'gdpr.privacy.link': 'Full privacy policy: {url}',
'gdpr.privacy.deleteHint': 'Delete personal data: `/gdpr delete confirm:true`',
'gdpr.delete.needConfirm': 'Set `confirm` to `true` to confirm deletion.',
'gdpr.delete.doneTitle': 'Data deleted',
'gdpr.delete.doneBody':
'Deleted/anonymized: {warnings} warnings, {levels} level rows, {economy} economy rows, {cases} cases anonymized.',
'moderation.success': 'Action executed successfully.',
'moderation.reason.none': 'No reason provided',
'moderation.warning.created': 'Warning created: {warningId}',
@@ -755,6 +781,7 @@ const en: Dictionary = {
'utility.embed.sent': 'Embed sent.',
'utility.embed.invalidColor': 'Invalid color. Use e.g. 6366F1.',
'utility.snipe.none': 'Nothing to show.',
'utility.snipe.disabled': 'Snipe is disabled on this server (privacy).',
'utility.snipe.privacy':
'Privacy: Snipe stores deleted/edited messages briefly (1 h) in Redis.',
'utility.snipe.deletedAt': 'Deleted message by {user}',
@@ -1014,6 +1041,8 @@ const en: Dictionary = {
'tag.error.invalid_name': 'Invalid tag name (132 chars, a-z, 0-9, _, -).',
'tag.error.content_required': 'Text tags require content.',
'tag.error.embed_required': 'Embed tags require a title or description.',
'tag.error.premium_limit': 'Tag limit reached for this premium tier.',
'tag.error.premium_feature': 'Tags are disabled for this premium tier.',
'guildbackup.description': 'Create and restore server backups',
'guildbackup.created': 'Created backup **{name}** (ID: `{id}`).',
'guildbackup.deleted': 'Deleted backup `{id}`.',
@@ -1048,6 +1077,8 @@ const en: Dictionary = {
'guildbackup.error.owner_only': 'Only the server owner can restore backups.',
'guildbackup.error.bot_missing_permissions':
'The bot is missing Manage Roles or Manage Channels for a restore.',
'guildbackup.error.premium_limit': 'Backup limit reached for this premium tier.',
'guildbackup.error.premium_feature': 'Backups are disabled for this premium tier.',
'feeds.added': 'Added feed `{id}` ({type}).',
'feeds.removed': 'Removed feed `{id}`.',
'feeds.list.header': '**Social Feeds**',
@@ -1060,6 +1091,8 @@ const en: Dictionary = {
'feeds.test.twitchSkipped':
'Twitch feed skipped: TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET are missing in the bot configuration.',
'feeds.error.not_found': 'Feed not found.',
'feeds.error.premium_limit': 'Feed limit reached for this premium tier.',
'feeds.error.premium_feature': 'Feeds are disabled for this premium tier.',
'scheduler.created': 'Scheduled message created (ID: `{id}`). {timing}',
'scheduler.created.at': 'Sends at: {time}',
'scheduler.created.cron': 'Repeats: `{cron}`',

View File

@@ -87,7 +87,8 @@ export type DashboardGuild = z.infer<typeof DashboardGuildSchema>;
export const GuildGeneralSettingsSchema = z.object({
locale: LocaleSchema,
timezone: z.string().min(1).max(64),
moderationEnabled: z.boolean()
moderationEnabled: z.boolean(),
snipeEnabled: z.boolean()
});
export type GuildGeneralSettings = z.infer<typeof GuildGeneralSettingsSchema>;

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import {
DEFAULT_PREMIUM_TIER_CONFIGS,
GuildPremiumAssignSchema,
isAssignmentActive,
limitKeyForResource,
PremiumTierConfigSchema
} from './premium.js';
describe('premium schemas', () => {
it('parses default free config', () => {
const parsed = PremiumTierConfigSchema.parse(DEFAULT_PREMIUM_TIER_CONFIGS.FREE);
expect(parsed.maxTags).toBe(15);
expect(parsed.features.feeds).toBe(true);
});
it('maps resources to limit keys', () => {
expect(limitKeyForResource('tags')).toBe('maxTags');
expect(limitKeyForResource('backups')).toBe('maxBackups');
});
it('validates guild assignment and expiry', () => {
expect(
GuildPremiumAssignSchema.parse({
guildId: '123456789012345678',
tier: 'PREMIUM',
note: null,
expiresAt: null
}).tier
).toBe('PREMIUM');
expect(isAssignmentActive(null)).toBe(true);
expect(isAssignmentActive(new Date(Date.now() - 1000))).toBe(false);
expect(isAssignmentActive(new Date(Date.now() + 60_000))).toBe(true);
});
});

View File

@@ -0,0 +1,94 @@
import { z } from 'zod';
const snowflake = z.string().regex(/^\d{17,20}$/);
export const PremiumTierSchema = z.enum(['FREE', 'PREMIUM']);
export type PremiumTier = z.infer<typeof PremiumTierSchema>;
export const PremiumFeatureKeySchema = z.enum(['tags', 'feeds', 'guildbackup']);
export type PremiumFeatureKey = z.infer<typeof PremiumFeatureKeySchema>;
export const PremiumFeaturesSchema = z.object({
tags: z.boolean(),
feeds: z.boolean(),
guildbackup: z.boolean()
});
export type PremiumFeatures = z.infer<typeof PremiumFeaturesSchema>;
export const PremiumTierConfigSchema = z.object({
tier: PremiumTierSchema,
maxTags: z.number().int().min(0).max(10_000),
maxFeeds: z.number().int().min(0).max(10_000),
maxBackups: z.number().int().min(0).max(10_000),
features: PremiumFeaturesSchema
});
export type PremiumTierConfig = z.infer<typeof PremiumTierConfigSchema>;
export const PremiumTierConfigPatchSchema = PremiumTierConfigSchema.omit({ tier: true }).partial().refine(
(value) => Object.keys(value).length > 0,
{ message: 'At least one field is required' }
);
export type PremiumTierConfigPatch = z.infer<typeof PremiumTierConfigPatchSchema>;
export const DEFAULT_PREMIUM_TIER_CONFIGS: Record<PremiumTier, PremiumTierConfig> = {
FREE: {
tier: 'FREE',
maxTags: 15,
maxFeeds: 3,
maxBackups: 2,
features: { tags: true, feeds: true, guildbackup: true }
},
PREMIUM: {
tier: 'PREMIUM',
maxTags: 100,
maxFeeds: 25,
maxBackups: 25,
features: { tags: true, feeds: true, guildbackup: true }
}
};
export const GuildPremiumAssignSchema = z.object({
guildId: snowflake,
tier: PremiumTierSchema,
note: z.string().max(500).nullable().optional(),
expiresAt: z.string().datetime().nullable().optional()
});
export type GuildPremiumAssign = z.infer<typeof GuildPremiumAssignSchema>;
export const UserPremiumAssignSchema = z.object({
userId: snowflake,
tier: PremiumTierSchema,
note: z.string().max(500).nullable().optional(),
expiresAt: z.string().datetime().nullable().optional()
});
export type UserPremiumAssign = z.infer<typeof UserPremiumAssignSchema>;
export const PremiumLimitResourceSchema = z.enum(['tags', 'feeds', 'backups']);
export type PremiumLimitResource = z.infer<typeof PremiumLimitResourceSchema>;
export function limitKeyForResource(resource: PremiumLimitResource): 'maxTags' | 'maxFeeds' | 'maxBackups' {
if (resource === 'tags') return 'maxTags';
if (resource === 'feeds') return 'maxFeeds';
return 'maxBackups';
}
export function featureKeyForResource(resource: PremiumLimitResource): PremiumFeatureKey {
if (resource === 'backups') return 'guildbackup';
return resource;
}
export function parsePremiumFeatures(raw: unknown): PremiumFeatures {
const parsed = PremiumFeaturesSchema.safeParse(raw);
if (parsed.success) {
return parsed.data;
}
return { tags: true, feeds: true, guildbackup: true };
}
export function isAssignmentActive(expiresAt: Date | string | null | undefined, now = new Date()): boolean {
if (!expiresAt) {
return true;
}
const date = typeof expiresAt === 'string' ? new Date(expiresAt) : expiresAt;
return date.getTime() > now.getTime();
}