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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 (1–32 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 (1–32 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}`',
|
||||
|
||||
@@ -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>;
|
||||
|
||||
35
packages/shared/src/premium.test.ts
Normal file
35
packages/shared/src/premium.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
94
packages/shared/src/premium.ts
Normal file
94
packages/shared/src/premium.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user