Add giveaway, ticket, self-role, starboard, and suggestion features to the bot

- Introduced new models in the Prisma schema for giveaways, tickets, self-role panels, starboard configurations, and suggestions, enhancing the bot's functionality.
- Implemented job handling for self-role expiration and ticket management, improving user experience.
- Updated command localization to support new features in both German and English.
- Enhanced the job processing system to include dedicated workers for managing tickets and self-role expirations.
- 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:08:37 +02:00
parent f2f856628d
commit a583db7a15
50 changed files with 7820 additions and 4 deletions

View File

@@ -682,6 +682,518 @@ export const commandLocales = {
'fun.common.options.opponent': {
de: 'Gegner',
en: 'Opponent'
},
'starboard.description': {
de: 'Starboard konfigurieren',
en: 'Configure starboard'
},
'starboard.setup.description': {
de: 'Starboard einrichten',
en: 'Set up starboard'
},
'starboard.setup.options.channel': {
de: 'Starboard-Kanal',
en: 'Starboard channel'
},
'starboard.setup.options.threshold': {
de: 'Mindestanzahl Reaktionen',
en: 'Minimum reaction count'
},
'starboard.setup.options.emoji': {
de: 'Emoji (Standard: ⭐)',
en: 'Emoji (default: ⭐)'
},
'starboard.setup.options.allow_self_star': {
de: 'Self-Star erlauben',
en: 'Allow self-star'
},
'suggest.description': {
de: 'Einen Vorschlag einreichen',
en: 'Submit a suggestion'
},
'suggest.options.content': {
de: 'Vorschlagstext',
en: 'Suggestion content'
},
'suggestion.description': {
de: 'Vorschläge verwalten',
en: 'Manage suggestions'
},
'suggestion.setup.description': {
de: 'Vorschlagssystem einrichten',
en: 'Set up suggestions'
},
'suggestion.setup.options.open_channel': {
de: 'Kanal für offene Vorschläge',
en: 'Open suggestions channel'
},
'suggestion.setup.options.approved_channel': {
de: 'Kanal für angenommene Vorschläge',
en: 'Approved suggestions channel'
},
'suggestion.setup.options.denied_channel': {
de: 'Kanal für abgelehnte Vorschläge',
en: 'Denied suggestions channel'
},
'suggestion.setup.options.create_thread': {
de: 'Diskussions-Thread erstellen',
en: 'Create discussion thread'
},
'suggestion.approve.description': {
de: 'Vorschlag annehmen',
en: 'Approve suggestion'
},
'suggestion.deny.description': {
de: 'Vorschlag ablehnen',
en: 'Deny suggestion'
},
'suggestion.consider.description': {
de: 'Vorschlag in Prüfung markieren',
en: 'Mark suggestion as under consideration'
},
'suggestion.implement.description': {
de: 'Vorschlag als umgesetzt markieren',
en: 'Mark suggestion as implemented'
},
'suggestion.options.suggestion_id': {
de: 'Vorschlags-ID',
en: 'Suggestion ID'
},
'suggestion.options.reason': {
de: 'Begründung',
en: 'Reason'
},
'giveaway.description': {
de: 'Giveaways verwalten',
en: 'Manage giveaways'
},
'giveaway.start.description': {
de: 'Giveaway starten',
en: 'Start a giveaway'
},
'giveaway.start.options.duration': {
de: 'Dauer (z. B. 1h, 2d)',
en: 'Duration (e.g. 1h, 2d)'
},
'giveaway.start.options.winners': {
de: 'Anzahl Gewinner',
en: 'Number of winners'
},
'giveaway.start.options.prize': {
de: 'Preis',
en: 'Prize'
},
'giveaway.start.options.role': {
de: 'Erforderliche Rolle',
en: 'Required role'
},
'giveaway.start.options.level': {
de: 'Mindest-Level',
en: 'Minimum level'
},
'giveaway.start.options.member_days': {
de: 'Mindest-Mitgliedschaft in Tagen',
en: 'Minimum membership in days'
},
'giveaway.end.description': {
de: 'Giveaway vorzeitig beenden',
en: 'End a giveaway early'
},
'giveaway.reroll.description': {
de: 'Gewinner neu ziehen',
en: 'Reroll winners'
},
'giveaway.list.description': {
de: 'Aktive Giveaways anzeigen',
en: 'List active giveaways'
},
'giveaway.delete.description': {
de: 'Giveaway löschen',
en: 'Delete a giveaway'
},
'giveaway.pause.description': {
de: 'Giveaway pausieren oder fortsetzen',
en: 'Pause or resume a giveaway'
},
'giveaway.common.options.id': {
de: 'Giveaway-ID',
en: 'Giveaway ID'
},
'ticket.description': {
de: 'Ticket-System verwalten',
en: 'Manage the ticket system'
},
'ticket.panel.description': {
de: 'Ticket-Panels',
en: 'Ticket panels'
},
'ticket.panel.create.description': {
de: 'Ticket-Panel erstellen',
en: 'Create a ticket panel'
},
'ticket.panel.create.options.name': {
de: 'Kategoriename',
en: 'Category name'
},
'ticket.panel.create.options.description': {
de: 'Panel-Beschreibung',
en: 'Panel description'
},
'ticket.category.description': {
de: 'Ticket-Kategorien',
en: 'Ticket categories'
},
'ticket.category.create.description': {
de: 'Ticket-Kategorie mit Support-Rolle erstellen',
en: 'Create a ticket category with support role'
},
'ticket.category.create.options.name': {
de: 'Kategoriename',
en: 'Category name'
},
'ticket.category.create.options.support_role': {
de: 'Support-Rolle',
en: 'Support role'
},
'ticket.close.description': {
de: 'Ticket schließen',
en: 'Close the ticket'
},
'ticket.close.options.reason': {
de: 'Optionaler Schließgrund',
en: 'Optional close reason'
},
'ticket.claim.description': {
de: 'Ticket übernehmen',
en: 'Claim the ticket'
},
'ticket.add.description': {
de: 'Nutzer zum Ticket hinzufügen',
en: 'Add a user to the ticket'
},
'ticket.remove.description': {
de: 'Nutzer aus dem Ticket entfernen',
en: 'Remove a user from the ticket'
},
'ticket.rename.description': {
de: 'Ticket umbenennen',
en: 'Rename the ticket'
},
'ticket.rename.options.name': {
de: 'Neuer Name',
en: 'New name'
},
'ticket.priority.description': {
de: 'Ticket-Priorität setzen',
en: 'Set ticket priority'
},
'ticket.priority.options.level': {
de: 'Priorität',
en: 'Priority'
},
'ticket.priority.choice.low': {
de: 'Niedrig',
en: 'Low'
},
'ticket.priority.choice.normal': {
de: 'Normal',
en: 'Normal'
},
'ticket.priority.choice.high': {
de: 'Hoch',
en: 'High'
},
'ticket.priority.choice.urgent': {
de: 'Dringend',
en: 'Urgent'
},
'ticket.common.options.user': {
de: 'Nutzer',
en: 'User'
},
'birthdays.description': {
de: 'Geburtstage verwalten und anzeigen',
en: 'Manage and view birthdays'
},
'birthdays.set.description': {
de: 'Eigenen Geburtstag eintragen',
en: 'Set your birthday'
},
'birthdays.set.options.month': {
de: 'Monat (112)',
en: 'Month (112)'
},
'birthdays.set.options.day': {
de: 'Tag (131)',
en: 'Day (131)'
},
'birthdays.set.options.year': {
de: 'Optionales Geburtsjahr',
en: 'Optional birth year'
},
'birthdays.remove.description': {
de: 'Eigenen Geburtstag entfernen',
en: 'Remove your birthday'
},
'birthdays.next.description': {
de: 'Nächste Geburtstage anzeigen',
en: 'Show upcoming birthdays'
},
'birthdays.list.description': {
de: 'Alle Geburtstage auflisten',
en: 'List all birthdays'
},
'birthdays.setup.description': {
de: 'Geburtstags-Modul einrichten',
en: 'Configure the birthday module'
},
'birthdays.setup.options.channel': {
de: 'Kanal für Glückwünsche',
en: 'Channel for congratulations'
},
'birthdays.setup.options.role': {
de: 'Optionale 24h-Geburtstagsrolle',
en: 'Optional 24h birthday role'
},
'birthdays.setup.options.timezone': {
de: 'Zeitzone (z. B. Europe/Berlin)',
en: 'Timezone (e.g. Europe/Berlin)'
},
'birthdays.setup.options.message': {
de: 'Nachrichtenvorlage ({user}, {age})',
en: 'Message template ({user}, {age})'
},
'tempvoice.description': {
de: 'Temp-Voice (Join to Create) steuern',
en: 'Control temp voice (join to create)'
},
'tempvoice.setup.description': {
de: 'Temp-Voice einrichten',
en: 'Configure temp voice'
},
'tempvoice.setup.options.hub_channel': {
de: 'Hub-Sprachkanal (Join to Create)',
en: 'Hub voice channel (join to create)'
},
'tempvoice.setup.options.category': {
de: 'Kategorie für neue Kanäle',
en: 'Category for new channels'
},
'tempvoice.setup.options.default_name': {
de: 'Standardname ({user})',
en: 'Default name ({user})'
},
'tempvoice.setup.options.limit': {
de: 'Standard-Nutzerlimit (0 = unbegrenzt)',
en: 'Default user limit (0 = unlimited)'
},
'tempvoice.rename.description': {
de: 'Eigenen Temp-Kanal umbenennen',
en: 'Rename your temp channel'
},
'tempvoice.rename.options.name': {
de: 'Neuer Kanalname',
en: 'New channel name'
},
'tempvoice.limit.description': {
de: 'Nutzerlimit des Temp-Kanals setzen',
en: 'Set temp channel user limit'
},
'tempvoice.limit.options.limit': {
de: 'Limit (0 = unbegrenzt)',
en: 'Limit (0 = unlimited)'
},
'tempvoice.lock.description': {
de: 'Temp-Kanal sperren',
en: 'Lock temp channel'
},
'tempvoice.hide.description': {
de: 'Temp-Kanal verstecken',
en: 'Hide temp channel'
},
'tempvoice.kick.description': {
de: 'Nutzer aus Temp-Kanal entfernen',
en: 'Kick user from temp channel'
},
'tempvoice.kick.options.user': {
de: 'Zielnutzer',
en: 'Target user'
},
'tempvoice.transfer.description': {
de: 'Besitz an anderen Nutzer übertragen',
en: 'Transfer ownership to another user'
},
'tempvoice.transfer.options.user': {
de: 'Neuer Besitzer',
en: 'New owner'
},
'tempvoice.claim.description': {
de: 'Temp-Kanal übernehmen wenn Besitzer weg ist',
en: 'Claim temp channel when owner left'
},
'tempvoice.panel.description': {
de: 'Control-Panel mit Buttons anzeigen',
en: 'Show control panel with buttons'
},
'selfroles.description': {
de: 'Self-Roles (Reaction Roles) verwalten',
en: 'Manage self roles (reaction roles)'
},
'selfroles.panel.description': {
de: 'Self-Role-Panels',
en: 'Self-role panels'
},
'selfroles.panel.create.description': {
de: 'Self-Role-Panel erstellen',
en: 'Create a self-role panel'
},
'selfroles.panel.edit.description': {
de: 'Self-Role-Panel bearbeiten',
en: 'Edit a self-role panel'
},
'selfroles.panel.delete.description': {
de: 'Self-Role-Panel löschen',
en: 'Delete a self-role panel'
},
'selfroles.panel.create.options.title': {
de: 'Panel-Titel',
en: 'Panel title'
},
'selfroles.panel.create.options.description': {
de: 'Panel-Beschreibung',
en: 'Panel description'
},
'selfroles.panel.create.options.mode': {
de: 'Darstellungsmodus',
en: 'Display mode'
},
'selfroles.panel.create.options.behavior': {
de: 'Rollen-Verhalten',
en: 'Role behavior'
},
'selfroles.panel.create.options.roles': {
de: 'Rollen (roleId:label:emoji, …)',
en: 'Roles (roleId:label:emoji, …)'
},
'selfroles.panel.create.options.channel': {
de: 'Zielkanal',
en: 'Target channel'
},
'selfroles.panel.create.options.duration': {
de: 'Temporäre Rollendauer (z. B. 24h)',
en: 'Temporary role duration (e.g. 24h)'
},
'selfroles.panel.common.options.panel_id': {
de: 'Panel-ID',
en: 'Panel ID'
},
'selfroles.choice.mode.buttons': {
de: 'Buttons',
en: 'Buttons'
},
'selfroles.choice.mode.dropdown': {
de: 'Dropdown',
en: 'Dropdown'
},
'selfroles.choice.mode.reactions': {
de: 'Reaktionen',
en: 'Reactions'
},
'selfroles.choice.behavior.normal': {
de: 'Normal (Toggle)',
en: 'Normal (toggle)'
},
'selfroles.choice.behavior.unique': {
de: 'Nur eine Rolle',
en: 'Unique (one role)'
},
'selfroles.choice.behavior.verify': {
de: 'Nur hinzufügen',
en: 'Verify (add only)'
},
'selfroles.choice.behavior.temporary': {
de: 'Temporär',
en: 'Temporary'
},
'tag.description': {
de: 'Custom Commands (Tags) verwalten',
en: 'Manage custom commands (tags)'
},
'tag.create.description': {
de: 'Tag erstellen',
en: 'Create a tag'
},
'tag.edit.description': {
de: 'Tag bearbeiten',
en: 'Edit a tag'
},
'tag.delete.description': {
de: 'Tag löschen',
en: 'Delete a tag'
},
'tag.list.description': {
de: 'Tags auflisten',
en: 'List tags'
},
'tag.info.description': {
de: 'Tag-Details anzeigen',
en: 'Show tag details'
},
'tag.run.description': {
de: 'Tag ausführen',
en: 'Run a tag'
},
'tag.common.options.name': {
de: 'Tag-Name',
en: 'Tag name'
},
'tag.edit.options.new_name': {
de: 'Neuer Name',
en: 'New name'
},
'tag.create.options.response_type': {
de: 'Antworttyp',
en: 'Response type'
},
'tag.create.options.content': {
de: 'Textinhalt',
en: 'Text content'
},
'tag.create.options.embed_title': {
de: 'Embed-Titel',
en: 'Embed title'
},
'tag.create.options.embed_description': {
de: 'Embed-Beschreibung',
en: 'Embed description'
},
'tag.create.options.embed_color': {
de: 'Embed-Farbe (Dezimal)',
en: 'Embed color (decimal)'
},
'tag.create.options.trigger_word': {
de: 'Trigger-Wort (Auto-Responder)',
en: 'Trigger word (auto-responder)'
},
'tag.create.options.allowed_roles': {
de: 'Erlaubte Rollen-IDs (kommagetrennt)',
en: 'Allowed role IDs (comma-separated)'
},
'tag.create.options.allowed_channels': {
de: 'Erlaubte Kanal-IDs (kommagetrennt)',
en: 'Allowed channel IDs (comma-separated)'
},
'tag.run.options.args': {
de: 'Argumente für Platzhalter {args}',
en: 'Arguments for {args} placeholder'
},
'tag.choice.response_type.text': {
de: 'Text',
en: 'Text'
},
'tag.choice.response_type.embed': {
de: 'Embed',
en: 'Embed'
}
} as const;

View File

@@ -5,6 +5,7 @@ export * from './command-locales.js';
export * from './discord-localization.js';
export * from './phase2.js';
export * from './phase3.js';
export * from './phase4.js';
export const LocaleSchema = z.enum(['de', 'en']);
export type Locale = z.infer<typeof LocaleSchema>;
@@ -238,7 +239,204 @@ const de: Dictionary = {
'fun.media.disabled': 'Medien-Commands sind auf diesem Server deaktiviert.',
'fun.media.fetchFailed': 'Bild konnte nicht geladen werden. Versuche es später erneut.',
'fun.cat.title': 'Random Cat',
'fun.dog.title': 'Random Dog'
'fun.dog.title': 'Random Dog',
'starboard.notConfigured': 'Starboard ist noch nicht konfiguriert. Nutze `/starboard setup`.',
'starboard.invalidChannel': 'Der Starboard-Kanal ist ungültig.',
'starboard.setupDone': 'Starboard konfiguriert.',
'starboard.noContent': '*Kein Textinhalt*',
'starboard.field.stars': 'Sterne',
'starboard.field.source': 'Quelle',
'starboard.jumpLink': 'Zur Nachricht',
'suggestion.created': 'Vorschlag eingereicht (ID: `{id}`).',
'suggestion.setupDone': 'Vorschlagssystem konfiguriert.',
'suggestion.staffUpdated': 'Vorschlag aktualisiert ({status}).',
'suggestion.votedUp': 'Upvote gezählt.',
'suggestion.votedDown': 'Downvote gezählt.',
'suggestion.threadName': 'Diskussion #{id}',
'suggestion.error.not_configured': 'Vorschläge sind nicht konfiguriert. Nutze `/suggestion setup`.',
'suggestion.error.invalid_channel': 'Der konfigurierte Kanal ist ungültig.',
'suggestion.error.not_found': 'Vorschlag nicht gefunden.',
'suggestion.error.not_open': 'Dieser Vorschlag ist nicht mehr offen.',
'suggestion.error.already_voted': 'Du hast bereits so abgestimmt.',
'suggestion.embed.title': 'Vorschlag #{id}',
'suggestion.embed.status': 'Status',
'suggestion.embed.votes': 'Stimmen',
'suggestion.embed.author': 'Von {author}',
'suggestion.embed.staffReason': 'Team-Begründung',
'suggestion.status.open': 'Offen',
'suggestion.status.approved': 'Angenommen',
'suggestion.status.denied': 'Abgelehnt',
'suggestion.status.considered': 'In Prüfung',
'suggestion.status.implemented': 'Umgesetzt',
'suggestion.dm.statusUpdate': 'Dein Vorschlag wurde aktualisiert: **{status}**\nBegründung: {reason}',
'giveaway.embed.title': 'Giveaway',
'giveaway.embed.prize': 'Preis',
'giveaway.embed.winners': 'Gewinner',
'giveaway.embed.entrants': 'Teilnehmer',
'giveaway.embed.host': 'Host',
'giveaway.embed.requirements': 'Anforderungen',
'giveaway.embed.active': 'Klicke auf **Join**, um teilzunehmen!',
'giveaway.embed.paused': '⏸️ Dieses Giveaway ist pausiert.',
'giveaway.embed.ended': 'Beendet',
'giveaway.embed.endedWinners': 'Gewinner: {winners}',
'giveaway.embed.endedNoWinners': 'Keine gültigen Teilnehmer.',
'giveaway.embed.endsAt': 'Endet {time}',
'giveaway.button.join': 'Teilnehmen',
'giveaway.requirement.role': 'Rolle: {role}',
'giveaway.requirement.level': 'Mindest-Level: {level}',
'giveaway.requirement.memberDays': 'Server-Mitglied seit mindestens {days} Tagen',
'giveaway.started': 'Giveaway gestartet (ID: `{id}`).',
'giveaway.ended': 'Giveaway beendet.',
'giveaway.rerolled': 'Neue Gewinner: {winners}',
'giveaway.deleted': 'Giveaway gelöscht.',
'giveaway.paused': 'Giveaway pausiert.',
'giveaway.resumed': 'Giveaway fortgesetzt.',
'giveaway.joined': 'Du nimmst am Giveaway teil!',
'giveaway.list.header': '**Aktive Giveaways**',
'giveaway.list.empty': 'Keine aktiven Giveaways.',
'giveaway.list.entrants': 'Teilnehmer',
'giveaway.dm.won': 'Glückwunsch! Du hast **{prize}** auf **{guild}** gewonnen!',
'giveaway.error.not_found': 'Giveaway nicht gefunden oder bereits beendet.',
'giveaway.error.not_ended': 'Giveaway ist noch nicht beendet.',
'giveaway.error.paused': 'Dieses Giveaway ist pausiert.',
'giveaway.error.already_joined': 'Du nimmst bereits teil.',
'giveaway.error.member_not_found': 'Mitglied nicht gefunden.',
'giveaway.error.missing_role': 'Dir fehlt die erforderliche Rolle.',
'giveaway.error.insufficient_level': 'Dein Level ist zu niedrig.',
'giveaway.error.insufficient_member_days': 'Du bist noch nicht lange genug auf dem Server.',
'giveaway.error.invalid_duration': 'Ungültige Dauer. Nutze z. B. 1h, 2d.',
'giveaway.error.duration_too_short': 'Die Dauer muss mindestens 1 Minute betragen.',
'giveaway.error.invalid_channel': 'Ungültiger Kanal.',
'ticket.panel.defaultDescription': 'Wähle eine Kategorie, um ein Ticket zu öffnen.',
'ticket.panel.created': 'Ticket-Panel erstellt.',
'ticket.category.created': 'Kategorie **{name}** erstellt.',
'ticket.created': 'Dein Ticket wurde erstellt: {channel}',
'ticket.opened': '{user} hat ein Ticket geöffnet.',
'ticket.subjectLine': 'Betreff: {subject}',
'ticket.claimed': '{user} hat dieses Ticket übernommen.',
'ticket.userAdded': '{user} wurde zum Ticket hinzugefügt.',
'ticket.userRemoved': '{user} wurde aus dem Ticket entfernt.',
'ticket.renamed': 'Ticket umbenannt in **{name}**.',
'ticket.prioritySet': 'Priorität gesetzt: **{priority}**',
'ticket.closed': 'Ticket geschlossen von {user}.',
'ticket.closedWithReason': 'Ticket geschlossen von {user}.\nGrund: {reason}',
'ticket.close.success': 'Ticket geschlossen.',
'ticket.claim.success': 'Ticket übernommen.',
'ticket.add.success': '{user} hinzugefügt.',
'ticket.remove.success': '{user} entfernt.',
'ticket.rename.success': 'Ticket umbenannt in **{name}**.',
'ticket.priority.success': 'Priorität: **{priority}**',
'ticket.transcriptDm': 'Hier ist dein Ticket-Transkript.',
'ticket.ratePrompt': 'Wie zufrieden warst du mit dem Support? (15)',
'ticket.rated': 'Danke für deine Bewertung: {rating}/5',
'ticket.autoCloseReason': 'Automatisch wegen Inaktivität geschlossen',
'ticket.modal.subject': 'Betreff',
'ticket.log.closed': 'Ticket geschlossen',
'ticket.log.opener': 'Ersteller',
'ticket.log.closedBy': 'Geschlossen von',
'ticket.priority.LOW': 'Niedrig',
'ticket.priority.NORMAL': 'Normal',
'ticket.priority.HIGH': 'Hoch',
'ticket.priority.URGENT': 'Dringend',
'ticket.error.disabled': 'Das Ticket-System ist deaktiviert.',
'ticket.error.category_not_found': 'Kategorie nicht gefunden.',
'ticket.error.no_categories': 'Keine Ticket-Kategorien vorhanden.',
'ticket.error.not_in_ticket': 'Dieser Befehl kann nur in einem Ticket-Kanal genutzt werden.',
'ticket.error.not_staff': 'Du hast keine Berechtigung für diese Ticket-Aktion.',
'ticket.error.already_open': 'Du hast bereits ein offenes Ticket.',
'ticket.error.closed': 'Dieses Ticket ist bereits geschlossen.',
'ticket.error.already_claimed': 'Du hast dieses Ticket bereits übernommen.',
'ticket.error.cannot_remove_opener': 'Der Ticket-Ersteller kann nicht entfernt werden.',
'ticket.error.no_channel': 'Ticket-Kanal nicht gefunden.',
'ticket.error.invalid_name': 'Ungültiger Name.',
'ticket.error.invalid_channel': 'Ungültiger Kanal.',
'ticket.error.no_parent_channel': 'Kein Elternkanal für Thread-Tickets konfiguriert.',
'ticket.error.invalid_parent_channel': 'Der konfigurierte Elternkanal ist ungültig.',
'ticket.error.not_found': 'Ticket nicht gefunden.',
'ticket.error.already_rated': 'Du hast dieses Ticket bereits bewertet.',
'birthdays.error.invalidDate': 'Ungültiges Datum. Prüfe Tag, Monat und optionales Jahr.',
'birthdays.error.invalidTimezone': 'Ungültige Zeitzone. Nutze z. B. Europe/Berlin.',
'birthdays.setup.done': 'Geburtstags-Modul konfiguriert.',
'birthdays.set.success': 'Geburtstag gespeichert: {day}.{month}.{year}',
'birthdays.remove.none': 'Du hast keinen Geburtstag eingetragen.',
'birthdays.remove.success': 'Dein Geburtstag wurde entfernt.',
'birthdays.list.empty': 'Noch keine Geburtstage eingetragen.',
'birthdays.list.header': '**Geburtstage (sortiert nach nächstem Datum):**',
'birthdays.next.header': '**Nächste Geburtstage:**',
'birthdays.list.line': '{rank}. {user} — {date} (in {days} T.)',
'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.',
'tempvoice.error.notInChannel': 'Du musst in einem Temp-Voice-Kanal sein.',
'tempvoice.error.notFound': 'Temp-Voice-Kanal nicht gefunden.',
'tempvoice.error.invalidName': 'Ungültiger Kanalname.',
'tempvoice.error.invalidLimit': 'Limit muss zwischen 0 und 99 liegen.',
'tempvoice.setup.done': 'Temp-Voice konfiguriert.',
'tempvoice.rename.success': 'Kanal umbenannt in **{name}**.',
'tempvoice.limit.success': 'Nutzerlimit auf **{limit}** gesetzt (0 = unbegrenzt).',
'tempvoice.lock.success': 'Kanal gesperrt (Connect für @everyone entfernt).',
'tempvoice.hide.success': 'Kanal versteckt (ViewChannel für @everyone entfernt).',
'tempvoice.kick.self': 'Du kannst dich nicht selbst kicken.',
'tempvoice.kick.notInChannel': 'Dieser Nutzer ist nicht in deinem Kanal.',
'tempvoice.kick.success': '{user} wurde aus dem Kanal entfernt.',
'tempvoice.transfer.self': 'Du kannst den Kanal nicht an dich selbst übertragen.',
'tempvoice.transfer.notInChannel': 'Der neue Besitzer muss in deinem Kanal sein.',
'tempvoice.transfer.success': 'Kanal an {user} übertragen.',
'tempvoice.claim.ownerPresent': 'Der Besitzer ist noch im Kanal.',
'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.button.rename': 'Umbenennen',
'tempvoice.button.limit': 'Limit',
'tempvoice.button.lock': 'Sperren',
'tempvoice.button.hide': 'Verstecken',
'tempvoice.button.kick': 'Kicken',
'tempvoice.button.transfer': 'Übertragen',
'tempvoice.button.claim': 'Übernehmen',
'selfroles.panel.defaultDescription': 'Wähle unten deine Rollen aus.',
'selfroles.panel.rolesField': 'Rollen',
'selfroles.panel.temporaryFooter': 'Rollen sind temporär und laufen automatisch ab.',
'selfroles.panel.created': 'Self-Role-Panel erstellt (ID: `{id}`).',
'selfroles.panel.updated': 'Self-Role-Panel aktualisiert (ID: `{id}`).',
'selfroles.panel.deleted': 'Self-Role-Panel gelöscht.',
'selfroles.role.added': 'Rolle hinzugefügt.',
'selfroles.role.removed': 'Rolle entfernt.',
'selfroles.role.verified': 'Rolle vergeben.',
'selfroles.role.unchanged': 'Du hast diese Rolle bereits.',
'selfroles.error.not_found': 'Self-Role-Panel nicht gefunden.',
'selfroles.error.invalid_roles': 'Ungültiges Rollen-Format. Nutze roleId:label:emoji.',
'selfroles.error.too_many_roles': 'Maximal 25 Rollen pro Panel.',
'selfroles.error.invalid_channel': 'Ungültiger Kanal.',
'selfroles.error.invalid_duration': 'Ungültige Dauer. Nutze z. B. 1h, 2d.',
'selfroles.error.duration_too_short': 'Die Dauer muss mindestens 1 Minute betragen.',
'selfroles.error.duration_required': 'Für temporäre Panels ist eine Dauer erforderlich.',
'selfroles.error.reaction_emoji_required': 'Reaktions-Modus erfordert Emojis für alle Rollen.',
'selfroles.error.member_not_found': 'Mitglied nicht gefunden.',
'selfroles.error.role_not_in_panel': 'Diese Rolle gehört nicht zu diesem Panel.',
'selfroles.error.role_not_found': 'Rolle nicht gefunden.',
'selfroles.error.role_hierarchy': 'Die Rolle liegt über der höchsten Bot-Rolle.',
'selfroles.error.role_managed': 'Integrierte Rollen können nicht vergeben werden.',
'selfroles.error.bot_missing_permissions': 'Dem Bot fehlt die Berechtigung Rollen zu verwalten.',
'tag.created': 'Tag `{name}` erstellt.',
'tag.updated': 'Tag `{name}` aktualisiert.',
'tag.deleted': 'Tag gelöscht.',
'tag.list.header': '**Tags**',
'tag.list.empty': 'Keine Tags vorhanden.',
'tag.list.uses': 'Aufrufe',
'tag.info.name': 'Name: `{name}`',
'tag.info.type': 'Typ: {type}',
'tag.info.uses': 'Aufrufe: {count}',
'tag.info.trigger': 'Trigger: `{word}`',
'tag.info.noTrigger': 'Kein Auto-Responder.',
'tag.info.roles': 'Rollen: {roles}',
'tag.info.allRoles': 'Rollen: alle',
'tag.info.channels': 'Kanäle: {channels}',
'tag.info.allChannels': 'Kanäle: alle',
'tag.error.not_found': 'Tag nicht gefunden.',
'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.'
};
const en: Dictionary = {
'generic.noPermission': 'You do not have permission for this action.',
@@ -439,7 +637,204 @@ const en: Dictionary = {
'fun.media.disabled': 'Media commands are disabled on this server.',
'fun.media.fetchFailed': 'Could not load image. Try again later.',
'fun.cat.title': 'Random Cat',
'fun.dog.title': 'Random Dog'
'fun.dog.title': 'Random Dog',
'starboard.notConfigured': 'Starboard is not configured yet. Use `/starboard setup`.',
'starboard.invalidChannel': 'The starboard channel is invalid.',
'starboard.setupDone': 'Starboard configured.',
'starboard.noContent': '*No text content*',
'starboard.field.stars': 'Stars',
'starboard.field.source': 'Source',
'starboard.jumpLink': 'Jump to message',
'suggestion.created': 'Suggestion submitted (ID: `{id}`).',
'suggestion.setupDone': 'Suggestion system configured.',
'suggestion.staffUpdated': 'Suggestion updated ({status}).',
'suggestion.votedUp': 'Upvote recorded.',
'suggestion.votedDown': 'Downvote recorded.',
'suggestion.threadName': 'Discussion #{id}',
'suggestion.error.not_configured': 'Suggestions are not configured. Use `/suggestion setup`.',
'suggestion.error.invalid_channel': 'The configured channel is invalid.',
'suggestion.error.not_found': 'Suggestion not found.',
'suggestion.error.not_open': 'This suggestion is no longer open.',
'suggestion.error.already_voted': 'You already voted this way.',
'suggestion.embed.title': 'Suggestion #{id}',
'suggestion.embed.status': 'Status',
'suggestion.embed.votes': 'Votes',
'suggestion.embed.author': 'By {author}',
'suggestion.embed.staffReason': 'Staff reason',
'suggestion.status.open': 'Open',
'suggestion.status.approved': 'Approved',
'suggestion.status.denied': 'Denied',
'suggestion.status.considered': 'Under consideration',
'suggestion.status.implemented': 'Implemented',
'suggestion.dm.statusUpdate': 'Your suggestion was updated: **{status}**\nReason: {reason}',
'giveaway.embed.title': 'Giveaway',
'giveaway.embed.prize': 'Prize',
'giveaway.embed.winners': 'Winners',
'giveaway.embed.entrants': 'Entrants',
'giveaway.embed.host': 'Host',
'giveaway.embed.requirements': 'Requirements',
'giveaway.embed.active': 'Click **Join** to enter!',
'giveaway.embed.paused': '⏸️ This giveaway is paused.',
'giveaway.embed.ended': 'Ended',
'giveaway.embed.endedWinners': 'Winners: {winners}',
'giveaway.embed.endedNoWinners': 'No valid entrants.',
'giveaway.embed.endsAt': 'Ends {time}',
'giveaway.button.join': 'Join',
'giveaway.requirement.role': 'Role: {role}',
'giveaway.requirement.level': 'Minimum level: {level}',
'giveaway.requirement.memberDays': 'Member for at least {days} days',
'giveaway.started': 'Giveaway started (ID: `{id}`).',
'giveaway.ended': 'Giveaway ended.',
'giveaway.rerolled': 'New winners: {winners}',
'giveaway.deleted': 'Giveaway deleted.',
'giveaway.paused': 'Giveaway paused.',
'giveaway.resumed': 'Giveaway resumed.',
'giveaway.joined': 'You entered the giveaway!',
'giveaway.list.header': '**Active giveaways**',
'giveaway.list.empty': 'No active giveaways.',
'giveaway.list.entrants': 'entrants',
'giveaway.dm.won': 'Congratulations! You won **{prize}** on **{guild}**!',
'giveaway.error.not_found': 'Giveaway not found or already ended.',
'giveaway.error.not_ended': 'Giveaway has not ended yet.',
'giveaway.error.paused': 'This giveaway is paused.',
'giveaway.error.already_joined': 'You already entered.',
'giveaway.error.member_not_found': 'Member not found.',
'giveaway.error.missing_role': 'You are missing the required role.',
'giveaway.error.insufficient_level': 'Your level is too low.',
'giveaway.error.insufficient_member_days': 'You have not been on the server long enough.',
'giveaway.error.invalid_duration': 'Invalid duration. Use e.g. 1h, 2d.',
'giveaway.error.duration_too_short': 'Duration must be at least 1 minute.',
'giveaway.error.invalid_channel': 'Invalid channel.',
'ticket.panel.defaultDescription': 'Select a category to open a ticket.',
'ticket.panel.created': 'Ticket panel created.',
'ticket.category.created': 'Category **{name}** created.',
'ticket.created': 'Your ticket was created: {channel}',
'ticket.opened': '{user} opened a ticket.',
'ticket.subjectLine': 'Subject: {subject}',
'ticket.claimed': '{user} claimed this ticket.',
'ticket.userAdded': '{user} was added to the ticket.',
'ticket.userRemoved': '{user} was removed from the ticket.',
'ticket.renamed': 'Ticket renamed to **{name}**.',
'ticket.prioritySet': 'Priority set to **{priority}**',
'ticket.closed': 'Ticket closed by {user}.',
'ticket.closedWithReason': 'Ticket closed by {user}.\nReason: {reason}',
'ticket.close.success': 'Ticket closed.',
'ticket.claim.success': 'Ticket claimed.',
'ticket.add.success': 'Added {user}.',
'ticket.remove.success': 'Removed {user}.',
'ticket.rename.success': 'Ticket renamed to **{name}**.',
'ticket.priority.success': 'Priority: **{priority}**',
'ticket.transcriptDm': 'Here is your ticket transcript.',
'ticket.ratePrompt': 'How satisfied were you with support? (15)',
'ticket.rated': 'Thanks for your rating: {rating}/5',
'ticket.autoCloseReason': 'Auto-closed due to inactivity',
'ticket.modal.subject': 'Subject',
'ticket.log.closed': 'Ticket closed',
'ticket.log.opener': 'Opener',
'ticket.log.closedBy': 'Closed by',
'ticket.priority.LOW': 'Low',
'ticket.priority.NORMAL': 'Normal',
'ticket.priority.HIGH': 'High',
'ticket.priority.URGENT': 'Urgent',
'ticket.error.disabled': 'The ticket system is disabled.',
'ticket.error.category_not_found': 'Category not found.',
'ticket.error.no_categories': 'No ticket categories configured.',
'ticket.error.not_in_ticket': 'This command can only be used inside a ticket channel.',
'ticket.error.not_staff': 'You do not have permission for this ticket action.',
'ticket.error.already_open': 'You already have an open ticket.',
'ticket.error.closed': 'This ticket is already closed.',
'ticket.error.already_claimed': 'You already claimed this ticket.',
'ticket.error.cannot_remove_opener': 'The ticket opener cannot be removed.',
'ticket.error.no_channel': 'Ticket channel not found.',
'ticket.error.invalid_name': 'Invalid name.',
'ticket.error.invalid_channel': 'Invalid channel.',
'ticket.error.no_parent_channel': 'No parent channel configured for thread tickets.',
'ticket.error.invalid_parent_channel': 'The configured parent channel is invalid.',
'ticket.error.not_found': 'Ticket not found.',
'ticket.error.already_rated': 'You already rated this ticket.',
'birthdays.error.invalidDate': 'Invalid date. Check day, month, and optional year.',
'birthdays.error.invalidTimezone': 'Invalid timezone. Use e.g. Europe/Berlin.',
'birthdays.setup.done': 'Birthday module configured.',
'birthdays.set.success': 'Birthday saved: {day}/{month}/{year}',
'birthdays.remove.none': 'You have not set a birthday.',
'birthdays.remove.success': 'Your birthday was removed.',
'birthdays.list.empty': 'No birthdays registered yet.',
'birthdays.list.header': '**Birthdays (sorted by next date):**',
'birthdays.next.header': '**Upcoming birthdays:**',
'birthdays.list.line': '{rank}. {user} — {date} (in {days} d)',
'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.',
'tempvoice.error.notInChannel': 'You must be in a temp voice channel.',
'tempvoice.error.notFound': 'Temp voice channel not found.',
'tempvoice.error.invalidName': 'Invalid channel name.',
'tempvoice.error.invalidLimit': 'Limit must be between 0 and 99.',
'tempvoice.setup.done': 'Temp voice configured.',
'tempvoice.rename.success': 'Channel renamed to **{name}**.',
'tempvoice.limit.success': 'User limit set to **{limit}** (0 = unlimited).',
'tempvoice.lock.success': 'Channel locked (Connect denied for @everyone).',
'tempvoice.hide.success': 'Channel hidden (ViewChannel denied for @everyone).',
'tempvoice.kick.self': 'You cannot kick yourself.',
'tempvoice.kick.notInChannel': 'That user is not in your channel.',
'tempvoice.kick.success': 'Removed {user} from the channel.',
'tempvoice.transfer.self': 'You cannot transfer the channel to yourself.',
'tempvoice.transfer.notInChannel': 'The new owner must be in your channel.',
'tempvoice.transfer.success': 'Channel transferred to {user}.',
'tempvoice.claim.ownerPresent': 'The owner is still in the channel.',
'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.button.rename': 'Rename',
'tempvoice.button.limit': 'Limit',
'tempvoice.button.lock': 'Lock',
'tempvoice.button.hide': 'Hide',
'tempvoice.button.kick': 'Kick',
'tempvoice.button.transfer': 'Transfer',
'tempvoice.button.claim': 'Claim',
'selfroles.panel.defaultDescription': 'Choose your roles below.',
'selfroles.panel.rolesField': 'Roles',
'selfroles.panel.temporaryFooter': 'Roles are temporary and expire automatically.',
'selfroles.panel.created': 'Self-role panel created (ID: `{id}`).',
'selfroles.panel.updated': 'Self-role panel updated (ID: `{id}`).',
'selfroles.panel.deleted': 'Self-role panel deleted.',
'selfroles.role.added': 'Role added.',
'selfroles.role.removed': 'Role removed.',
'selfroles.role.verified': 'Role granted.',
'selfroles.role.unchanged': 'You already have this role.',
'selfroles.error.not_found': 'Self-role panel not found.',
'selfroles.error.invalid_roles': 'Invalid roles format. Use roleId:label:emoji.',
'selfroles.error.too_many_roles': 'Maximum 25 roles per panel.',
'selfroles.error.invalid_channel': 'Invalid channel.',
'selfroles.error.invalid_duration': 'Invalid duration. Use e.g. 1h, 2d.',
'selfroles.error.duration_too_short': 'Duration must be at least 1 minute.',
'selfroles.error.duration_required': 'Temporary panels require a duration.',
'selfroles.error.reaction_emoji_required': 'Reaction mode requires emojis for all roles.',
'selfroles.error.member_not_found': 'Member not found.',
'selfroles.error.role_not_in_panel': 'This role is not part of this panel.',
'selfroles.error.role_not_found': 'Role not found.',
'selfroles.error.role_hierarchy': 'The role is above the bot\'s highest role.',
'selfroles.error.role_managed': 'Managed roles cannot be assigned.',
'selfroles.error.bot_missing_permissions': 'The bot lacks Manage Roles permission.',
'tag.created': 'Tag `{name}` created.',
'tag.updated': 'Tag `{name}` updated.',
'tag.deleted': 'Tag deleted.',
'tag.list.header': '**Tags**',
'tag.list.empty': 'No tags configured.',
'tag.list.uses': 'uses',
'tag.info.name': 'Name: `{name}`',
'tag.info.type': 'Type: {type}',
'tag.info.uses': 'Uses: {count}',
'tag.info.trigger': 'Trigger: `{word}`',
'tag.info.noTrigger': 'No auto-responder.',
'tag.info.roles': 'Roles: {roles}',
'tag.info.allRoles': 'Roles: all',
'tag.info.channels': 'Channels: {channels}',
'tag.info.allChannels': 'Channels: all',
'tag.error.not_found': 'Tag not found.',
'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.'
};
const locales: Record<Locale, Dictionary> = { de, en };

View File

@@ -0,0 +1,84 @@
import { z } from 'zod';
export const GiveawayStatusSchema = z.enum(['ACTIVE', 'PAUSED', 'ENDED']);
export type GiveawayStatus = z.infer<typeof GiveawayStatusSchema>;
export const TicketModeSchema = z.enum(['CHANNEL', 'THREAD']);
export type TicketMode = z.infer<typeof TicketModeSchema>;
export const TicketStatusSchema = z.enum(['OPEN', 'CLAIMED', 'CLOSED']);
export type TicketStatus = z.infer<typeof TicketStatusSchema>;
export const TicketPrioritySchema = z.enum(['LOW', 'NORMAL', 'HIGH', 'URGENT']);
export type TicketPriority = z.infer<typeof TicketPrioritySchema>;
export const SelfRoleModeSchema = z.enum(['BUTTONS', 'DROPDOWN', 'REACTIONS']);
export type SelfRoleMode = z.infer<typeof SelfRoleModeSchema>;
export const SelfRoleBehaviorSchema = z.enum(['NORMAL', 'UNIQUE', 'VERIFY', 'TEMPORARY']);
export type SelfRoleBehavior = z.infer<typeof SelfRoleBehaviorSchema>;
export const SelfRoleEntrySchema = z.object({
roleId: z.string(),
label: z.string(),
emoji: z.string().optional(),
description: z.string().optional()
});
export type SelfRoleEntry = z.infer<typeof SelfRoleEntrySchema>;
export const TagResponseTypeSchema = z.enum(['TEXT', 'EMBED']);
export type TagResponseType = z.infer<typeof TagResponseTypeSchema>;
export const SuggestionStatusSchema = z.enum([
'OPEN',
'APPROVED',
'DENIED',
'CONSIDERED',
'IMPLEMENTED'
]);
export type SuggestionStatus = z.infer<typeof SuggestionStatusSchema>;
export function applyTagPlaceholders(
template: string,
vars: Record<string, string>
): string {
return Object.entries(vars).reduce(
(acc, [key, value]) => acc.replaceAll(`{${key}}`, value),
template
);
}
export function pickRandomWinners<T>(items: T[], count: number): T[] {
const pool = [...items];
const winners: T[] = [];
while (winners.length < count && pool.length > 0) {
const index = Math.floor(Math.random() * pool.length);
winners.push(pool.splice(index, 1)[0]!);
}
return winners;
}
export function escapeHtml(input: string): string {
return input
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
export function buildTicketTranscriptHtml(input: {
guildName: string;
ticketId: string;
openerTag: string;
messages: Array<{ author: string; content: string; timestamp: string }>;
}): string {
const rows = input.messages
.map(
(m) =>
`<div class="msg"><span class="meta">${escapeHtml(m.timestamp)}${escapeHtml(m.author)}</span><div>${escapeHtml(m.content)}</div></div>`
)
.join('\n');
return `<!DOCTYPE html><html><head><meta charset="utf-8"/><title>Ticket ${escapeHtml(input.ticketId)}</title>
<style>body{font-family:sans-serif;background:#111;color:#eee;padding:1rem}.msg{margin:.75rem 0;padding:.5rem;border-left:3px solid #6366f1}.meta{color:#9ca3af;font-size:.85rem}</style>
</head><body><h1>Ticket ${escapeHtml(input.ticketId)}</h1><p>${escapeHtml(input.guildName)}${escapeHtml(input.openerTag)}</p>${rows}</body></html>`;
}