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

@@ -963,6 +963,58 @@ export const commandLocales = {
de: 'Nachrichtenvorlage ({user}, {age})',
en: 'Message template ({user}, {age})'
},
'stats.description': {
de: 'Server-Statistik-Kanäle verwalten',
en: 'Manage server statistics channels'
},
'stats.setup.description': {
de: 'Statistik-Kanäle einrichten',
en: 'Configure statistics channels'
},
'stats.setup.options.members_channel': {
de: 'Kanal für Mitgliederzahl',
en: 'Channel for member count'
},
'stats.setup.options.online_channel': {
de: 'Kanal für Online-Anzahl',
en: 'Channel for online count'
},
'stats.setup.options.boosts_channel': {
de: 'Kanal für Boost-Anzahl',
en: 'Channel for boost count'
},
'stats.setup.options.members_template': {
de: 'Vorlage Mitglieder ({count})',
en: 'Members template ({count})'
},
'stats.setup.options.online_template': {
de: 'Vorlage Online ({count})',
en: 'Online template ({count})'
},
'stats.setup.options.boosts_template': {
de: 'Vorlage Boosts ({count})',
en: 'Boosts template ({count})'
},
'stats.refresh.description': {
de: 'Statistik-Kanäle jetzt aktualisieren',
en: 'Refresh statistics channels now'
},
'invites.description': {
de: 'Invite-Statistiken anzeigen',
en: 'Show invite statistics'
},
'invites.view.description': {
de: 'Invite-Statistik eines Nutzers anzeigen',
en: 'Show invite stats for a user'
},
'invites.view.options.user': {
de: 'Nutzer (Standard: du)',
en: 'User (defaults to you)'
},
'invites.leaderboard.description': {
de: 'Top-Einlader anzeigen',
en: 'Show top inviters'
},
'tempvoice.description': {
de: 'Temp-Voice (Join to Create) steuern',
en: 'Control temp voice (join to create)'
@@ -1194,6 +1246,158 @@ export const commandLocales = {
'tag.choice.response_type.embed': {
de: 'Embed',
en: 'Embed'
},
'feeds.description': {
de: 'Social-Feeds verwalten',
en: 'Manage social feeds'
},
'feeds.add.description': {
de: 'Feed hinzufügen',
en: 'Add feed'
},
'feeds.add.options.type': {
de: 'Feed-Typ',
en: 'Feed type'
},
'feeds.add.options.source': {
de: 'Quelle (Kanal, URL oder Subreddit)',
en: 'Source (channel, URL, or subreddit)'
},
'feeds.add.options.channel': {
de: 'Ziel-Kanal',
en: 'Target channel'
},
'feeds.add.options.role': {
de: 'Rolle zum Pingen',
en: 'Role to ping'
},
'feeds.add.options.template': {
de: 'Nachrichtenvorlage ({title}, {url})',
en: 'Message template ({title}, {url})'
},
'feeds.remove.description': {
de: 'Feed entfernen',
en: 'Remove feed'
},
'feeds.remove.options.id': {
de: 'Feed-ID',
en: 'Feed ID'
},
'feeds.list.description': {
de: 'Feeds auflisten',
en: 'List feeds'
},
'feeds.test.description': {
de: 'Feed einmal abrufen und posten',
en: 'Fetch once and post'
},
'feeds.test.options.id': {
de: 'Feed-ID',
en: 'Feed ID'
},
'feeds.choice.type.twitch': {
de: 'Twitch',
en: 'Twitch'
},
'feeds.choice.type.youtube': {
de: 'YouTube',
en: 'YouTube'
},
'feeds.choice.type.rss': {
de: 'RSS',
en: 'RSS'
},
'feeds.choice.type.reddit': {
de: 'Reddit',
en: 'Reddit'
},
'schedule.description': {
de: 'Geplante Nachrichten verwalten',
en: 'Manage scheduled messages'
},
'schedule.create.description': {
de: 'Nachricht planen',
en: 'Schedule a message'
},
'schedule.list.description': {
de: 'Geplante Nachrichten anzeigen',
en: 'List scheduled messages'
},
'schedule.delete.description': {
de: 'Geplante Nachricht löschen',
en: 'Delete a scheduled message'
},
'schedule.create.options.when': {
de: 'Einmalig: Dauer (z. B. 1h) oder ISO-Datum',
en: 'One-shot: duration (e.g. 1h) or ISO date'
},
'schedule.create.options.cron': {
de: 'Wiederkehrend: Cron-Ausdruck',
en: 'Recurring: cron expression'
},
'schedule.common.options.channel': {
de: 'Zielkanal',
en: 'Target channel'
},
'schedule.common.options.content': {
de: 'Nachrichtentext',
en: 'Message text'
},
'schedule.common.options.embed_title': {
de: 'Embed-Titel',
en: 'Embed title'
},
'schedule.common.options.embed_description': {
de: 'Embed-Beschreibung',
en: 'Embed description'
},
'schedule.common.options.embed_color': {
de: 'Embed-Farbe (Dezimal)',
en: 'Embed color (decimal)'
},
'schedule.common.options.role': {
de: 'Zu pingende Rolle',
en: 'Role to ping'
},
'schedule.common.options.id': {
de: 'ID der geplanten Nachricht',
en: 'Scheduled message ID'
},
'announce.description': {
de: 'Sofortige Ankündigung senden',
en: 'Send an immediate announcement'
},
'guildbackup.description': {
de: 'Server-Backups erstellen und wiederherstellen',
en: 'Create and restore server backups'
},
'guildbackup.create.description': {
de: 'Snapshot von Rollen, Kanälen und Einstellungen erstellen',
en: 'Create a snapshot of roles, channels, and settings'
},
'guildbackup.create.options.name': {
de: 'Name des Backups',
en: 'Backup name'
},
'guildbackup.list.description': {
de: 'Backups dieses Servers auflisten',
en: 'List backups for this server'
},
'guildbackup.info.description': {
de: 'Details zu einem Backup anzeigen',
en: 'Show backup details'
},
'guildbackup.delete.description': {
de: 'Backup löschen',
en: 'Delete a backup'
},
'guildbackup.restore.description': {
de: 'Backup wiederherstellen (nur Owner)',
en: 'Restore a backup (owner only)'
},
'guildbackup.common.options.id': {
de: 'Backup-ID',
en: 'Backup ID'
}
} as const;

View File

@@ -6,6 +6,7 @@ export * from './discord-localization.js';
export * from './phase2.js';
export * from './phase3.js';
export * from './phase4.js';
export * from './phase5.js';
export const LocaleSchema = z.enum(['de', 'en']);
export type Locale = z.infer<typeof LocaleSchema>;
@@ -364,6 +365,17 @@ const de: Dictionary = {
'birthdays.list.header': '**Geburtstage (sortiert nach nächstem Datum):**',
'birthdays.next.header': '**Nächste Geburtstage:**',
'birthdays.list.line': '{rank}. {user} — {date} (in {days} T.)',
'stats.notConfigured': 'Server-Statistiken sind noch nicht eingerichtet. Nutze `/stats setup`.',
'stats.setup.done': 'Statistik-Kanäle konfiguriert und aktualisiert.',
'stats.refresh.done': 'Statistik-Kanäle wurden aktualisiert.',
'stats.error.invalidSetup': 'Ungültige Eingabe für die Statistik-Konfiguration.',
'invites.view.empty': '{user} hat noch keine Invite-Statistiken.',
'invites.view.result':
'**Invite-Statistik für {user}**\nJoins: {joins} | Leaves: {leaves} | Fakes: {fakes} | Netto: {net}',
'invites.leaderboard.empty': 'Noch keine Invite-Daten vorhanden.',
'invites.leaderboard.header': '**Top Einlader:**',
'invites.leaderboard.line':
'{rank}. {user} — Joins: {joins}, Leaves: {leaves}, Fakes: {fakes}, Netto: {net}',
'tempvoice.error.disabled': 'Temp-Voice ist auf diesem Server deaktiviert.',
'tempvoice.error.invalidCategory': 'Ungültige Kategorie.',
'tempvoice.error.notOwner': 'Du bist nicht der Besitzer dieses Temp-Voice-Kanals.',
@@ -386,6 +398,7 @@ const de: Dictionary = {
'tempvoice.claim.success': 'Du bist jetzt Besitzer dieses Kanals.',
'tempvoice.panel.title': 'Temp-Voice Steuerung',
'tempvoice.panel.description': 'Verwalte **{channel}** mit den Buttons oder `/voice`-Commands.',
'tempvoice.panel.sent': 'Control-Panel wurde in den Voice-Kanal-Chat gesendet.',
'tempvoice.button.rename': 'Umbenennen',
'tempvoice.button.limit': 'Limit',
'tempvoice.button.lock': 'Sperren',
@@ -436,7 +449,70 @@ const de: Dictionary = {
'tag.error.not_allowed': 'Du darfst diesen Tag hier nicht nutzen.',
'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.embed_required': 'Embed-Tags benötigen Titel oder Beschreibung.',
'guildbackup.description': 'Server-Backups erstellen und wiederherstellen',
'guildbackup.created': 'Backup **{name}** erstellt (ID: `{id}`).',
'guildbackup.deleted': 'Backup `{id}` gelöscht.',
'guildbackup.list.header': '**Server-Backups**',
'guildbackup.list.empty': 'Keine Backups vorhanden.',
'guildbackup.list.line': '• `{id}` — **{name}** ({date})',
'guildbackup.info.id': 'ID: `{id}`',
'guildbackup.info.name': 'Name: **{name}**',
'guildbackup.info.created': 'Erstellt: {date}',
'guildbackup.info.createdBy': 'Erstellt von: <@{userId}>',
'guildbackup.info.roles': 'Rollen: {count}',
'guildbackup.info.channels': 'Kanäle: {count}',
'guildbackup.info.limitations':
'Hinweis: Backups enthalten keine Nachrichten. Beim Restore werden IDs nicht übernommen; fehlende Rollen/Kanäle werden nach Name ergänzt, bestehende nicht gelöscht.',
'guildbackup.confirm.delete': 'Backup `{id}` wirklich löschen?',
'guildbackup.confirm.restore.step1':
'Backup `{id}` wiederherstellen? Es werden fehlende Rollen/Kanäle ergänzt und Overwrites best-effort angewendet. Bestehende Kanäle werden **nicht** gelöscht.',
'guildbackup.confirm.restore.step2':
'**Letzte Bestätigung:** Backup `{id}` jetzt wiederherstellen? Diese Aktion kann nicht rückgängig gemacht werden.',
'guildbackup.confirm.button.confirm': 'Bestätigen',
'guildbackup.confirm.button.cancel': 'Abbrechen',
'guildbackup.confirm.expired': 'Diese Bestätigung ist abgelaufen.',
'guildbackup.confirm.unauthorized': 'Nur der auslösende Nutzer kann das bestätigen.',
'guildbackup.confirm.cancelled': 'Abgebrochen.',
'guildbackup.restore.done':
'Backup `{id}` wiederhergestellt. Rollen: {rolesCreated} neu, {rolesSkipped} übersprungen. Kanäle: {channelsCreated} neu, {channelsUpdated} aktualisiert. Overwrites: {overwritesApplied}.',
'guildbackup.restore.queued':
'Großes Backup `{id}` — Restore wurde in die Warteschlange gestellt und läuft im Hintergrund.',
'guildbackup.error.not_found': 'Backup nicht gefunden.',
'guildbackup.error.invalid_payload': 'Backup-Daten sind ungültig oder veraltet.',
'guildbackup.error.invalid_name': 'Ungültiger Backup-Name (1100 Zeichen).',
'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.',
'feeds.added': 'Feed `{id}` ({type}) hinzugefügt.',
'feeds.removed': 'Feed `{id}` entfernt.',
'feeds.list.header': '**Social Feeds**',
'feeds.list.empty': 'Keine Feeds konfiguriert.',
'feeds.list.line': '`{id}` — {type}: {source} → {channel}',
'feeds.test.posted': 'Test erfolgreich: {count} Nachricht(en) gesendet.',
'feeds.test.noItems': 'Keine neuen Einträge gefunden.',
'feeds.test.failed': 'Abruf fehlgeschlagen: {reason}',
'feeds.test.skipped': 'Feed-Test übersprungen.',
'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.',
'scheduler.created': 'Geplante Nachricht erstellt (ID: `{id}`). {timing}',
'scheduler.created.at': 'Sendung: {time}',
'scheduler.created.cron': 'Wiederholung: `{cron}`',
'scheduler.deleted': 'Geplante Nachricht gelöscht.',
'scheduler.announce.sent': 'Ankündigung gesendet.',
'scheduler.list.header': '**Geplante Nachrichten**',
'scheduler.list.empty': 'Keine geplanten Nachrichten.',
'scheduler.list.line': '`{id}` — {channel} — {timing} — {preview}',
'scheduler.list.cron': 'Cron `{cron}`',
'scheduler.list.embedPreview': '[Embed]',
'scheduler.error.missing_schedule_time': 'Gib `when` oder `cron` an.',
'scheduler.error.invalid_when': 'Ungültige Zeit. Nutze z. B. 1h, 2d oder ein ISO-Datum.',
'scheduler.error.invalid_cron': 'Ungültiger Cron-Ausdruck (56 Felder).',
'scheduler.error.invalid_channel': 'Ungültiger Kanal.',
'scheduler.error.channel_unavailable': 'Der Bot kann in diesem Kanal nicht senden.',
'scheduler.error.not_found': 'Geplante Nachricht nicht gefunden.',
'scheduler.error.content_required': 'Inhalt oder Embed-Titel/Beschreibung erforderlich.'
};
const en: Dictionary = {
'generic.noPermission': 'You do not have permission for this action.',
@@ -762,6 +838,17 @@ const en: Dictionary = {
'birthdays.list.header': '**Birthdays (sorted by next date):**',
'birthdays.next.header': '**Upcoming birthdays:**',
'birthdays.list.line': '{rank}. {user} — {date} (in {days} d)',
'stats.notConfigured': 'Server statistics are not configured yet. Use `/stats setup`.',
'stats.setup.done': 'Stats channels configured and updated.',
'stats.refresh.done': 'Stats channels have been refreshed.',
'stats.error.invalidSetup': 'Invalid input for stats configuration.',
'invites.view.empty': '{user} has no invite statistics yet.',
'invites.view.result':
'**Invite stats for {user}**\nJoins: {joins} | Leaves: {leaves} | Fakes: {fakes} | Net: {net}',
'invites.leaderboard.empty': 'No invite data available yet.',
'invites.leaderboard.header': '**Top inviters:**',
'invites.leaderboard.line':
'{rank}. {user} — Joins: {joins}, Leaves: {leaves}, Fakes: {fakes}, Net: {net}',
'tempvoice.error.disabled': 'Temp voice is disabled on this server.',
'tempvoice.error.invalidCategory': 'Invalid category.',
'tempvoice.error.notOwner': 'You are not the owner of this temp voice channel.',
@@ -784,6 +871,7 @@ const en: Dictionary = {
'tempvoice.claim.success': 'You are now the owner of this channel.',
'tempvoice.panel.title': 'Temp Voice Control',
'tempvoice.panel.description': 'Manage **{channel}** with the buttons below or `/voice` commands.',
'tempvoice.panel.sent': 'Control panel posted in the voice channel chat.',
'tempvoice.button.rename': 'Rename',
'tempvoice.button.limit': 'Limit',
'tempvoice.button.lock': 'Lock',
@@ -834,7 +922,70 @@ const en: Dictionary = {
'tag.error.not_allowed': 'You are not allowed to use this tag here.',
'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.embed_required': 'Embed tags require a title or description.',
'guildbackup.description': 'Create and restore server backups',
'guildbackup.created': 'Created backup **{name}** (ID: `{id}`).',
'guildbackup.deleted': 'Deleted backup `{id}`.',
'guildbackup.list.header': '**Server backups**',
'guildbackup.list.empty': 'No backups found.',
'guildbackup.list.line': '• `{id}` — **{name}** ({date})',
'guildbackup.info.id': 'ID: `{id}`',
'guildbackup.info.name': 'Name: **{name}**',
'guildbackup.info.created': 'Created: {date}',
'guildbackup.info.createdBy': 'Created by: <@{userId}>',
'guildbackup.info.roles': 'Roles: {count}',
'guildbackup.info.channels': 'Channels: {count}',
'guildbackup.info.limitations':
'Note: Backups do not include messages. Restore cannot preserve IDs; missing roles/channels are added by name, existing channels are not deleted.',
'guildbackup.confirm.delete': 'Really delete backup `{id}`?',
'guildbackup.confirm.restore.step1':
'Restore backup `{id}`? Missing roles/channels will be added and overwrites applied best-effort. Existing channels will **not** be deleted.',
'guildbackup.confirm.restore.step2':
'**Final confirmation:** Restore backup `{id}` now? This cannot be undone.',
'guildbackup.confirm.button.confirm': 'Confirm',
'guildbackup.confirm.button.cancel': 'Cancel',
'guildbackup.confirm.expired': 'This confirmation has expired.',
'guildbackup.confirm.unauthorized': 'Only the user who started this action can confirm.',
'guildbackup.confirm.cancelled': 'Cancelled.',
'guildbackup.restore.done':
'Restored backup `{id}`. Roles: {rolesCreated} created, {rolesSkipped} skipped. Channels: {channelsCreated} created, {channelsUpdated} updated. Overwrites: {overwritesApplied}.',
'guildbackup.restore.queued':
'Large backup `{id}` — restore queued and will run in the background.',
'guildbackup.error.not_found': 'Backup not found.',
'guildbackup.error.invalid_payload': 'Backup data is invalid or outdated.',
'guildbackup.error.invalid_name': 'Invalid backup name (1100 characters).',
'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.',
'feeds.added': 'Added feed `{id}` ({type}).',
'feeds.removed': 'Removed feed `{id}`.',
'feeds.list.header': '**Social Feeds**',
'feeds.list.empty': 'No feeds configured.',
'feeds.list.line': '`{id}` — {type}: {source} → {channel}',
'feeds.test.posted': 'Test successful: sent {count} message(s).',
'feeds.test.noItems': 'No new items found.',
'feeds.test.failed': 'Fetch failed: {reason}',
'feeds.test.skipped': 'Feed test skipped.',
'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.',
'scheduler.created': 'Scheduled message created (ID: `{id}`). {timing}',
'scheduler.created.at': 'Sends at: {time}',
'scheduler.created.cron': 'Repeats: `{cron}`',
'scheduler.deleted': 'Scheduled message deleted.',
'scheduler.announce.sent': 'Announcement sent.',
'scheduler.list.header': '**Scheduled messages**',
'scheduler.list.empty': 'No scheduled messages.',
'scheduler.list.line': '`{id}` — {channel} — {timing} — {preview}',
'scheduler.list.cron': 'Cron `{cron}`',
'scheduler.list.embedPreview': '[Embed]',
'scheduler.error.missing_schedule_time': 'Provide `when` or `cron`.',
'scheduler.error.invalid_when': 'Invalid time. Use e.g. 1h, 2d, or an ISO date.',
'scheduler.error.invalid_cron': 'Invalid cron expression (56 fields).',
'scheduler.error.invalid_channel': 'Invalid channel.',
'scheduler.error.channel_unavailable': 'The bot cannot send messages in that channel.',
'scheduler.error.not_found': 'Scheduled message not found.',
'scheduler.error.content_required': 'Content or embed title/description is required.'
};
const locales: Record<Locale, Dictionary> = { de, en };

View File

@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { applyCountTemplate, applyFeedTemplate } from './phase5.js';
describe('phase5 helpers', () => {
it('applies count template', () => {
expect(applyCountTemplate('Members: {count}', 42)).toBe('Members: 42');
});
it('applies feed template', () => {
expect(applyFeedTemplate('{title} - {url}', { title: 'Hi', url: 'https://x.test' })).toBe(
'Hi - https://x.test'
);
});
});

View File

@@ -0,0 +1,66 @@
import { z } from 'zod';
export const SocialFeedTypeSchema = z.enum(['TWITCH', 'YOUTUBE', 'RSS', 'REDDIT']);
export type SocialFeedType = z.infer<typeof SocialFeedTypeSchema>;
export const GuildBackupPayloadSchema = z.object({
version: z.literal(1),
guildName: z.string(),
roles: z.array(
z.object({
id: z.string(),
name: z.string(),
color: z.number(),
hoist: z.boolean(),
mentionable: z.boolean(),
permissions: z.string(),
position: z.number()
})
),
channels: z.array(
z.object({
id: z.string(),
name: z.string(),
type: z.number(),
parentId: z.string().nullable(),
position: z.number(),
topic: z.string().nullable().optional(),
nsfw: z.boolean().optional(),
rateLimitPerUser: z.number().optional(),
bitrate: z.number().optional(),
userLimit: z.number().optional(),
permissionOverwrites: z.array(
z.object({
id: z.string(),
type: z.number(),
allow: z.string(),
deny: z.string()
})
)
})
),
settings: z
.object({
name: z.string().optional(),
verificationLevel: z.number().optional(),
explicitContentFilter: z.number().optional(),
defaultMessageNotifications: z.number().optional(),
afkTimeout: z.number().optional()
})
.optional()
});
export type GuildBackupPayload = z.infer<typeof GuildBackupPayloadSchema>;
export function applyCountTemplate(template: string, count: number): string {
return template.replaceAll('{count}', String(count));
}
export function applyFeedTemplate(
template: string,
vars: Record<string, string>
): string {
return Object.entries(vars).reduce(
(acc, [key, value]) => acc.replaceAll(`{${key}}`, value),
template
);
}