diff --git a/README.md b/README.md index 3e8652c..116e940 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St | 📦 **Commit-Feed** | Gitea-Push-Webhooks → Embeds in den (privaten) Commit-Kanal | | 🚀 **Release-Ankündigungen** | Neues Gitea-Release → oranges Ankündigungs-Embed | | 📊 **Wochen-Rückblick** | Sonntags 20:00 automatisch: Commits pro Tag als Balken, Devlogs, Top-Projekte | -| 🐛 **`/bug`** | Member melden Bugs → Gitea-Issue (inkl. Screenshot-Upload); Issue geschlossen → DM an den Reporter | +| 🐛 **`/bug`** | Eingabefenster mit vier Feldern (was ist passiert, was war erwartet, wie nachstellen) → Gitea-Issue inkl. Screenshot-Upload; Issue geschlossen → DM an den Reporter | | 🚨 **Watchdog** | Prüft eigene Dienste alle 2 min, DM-Alarm bei Ausfall + Entwarnung | | 🧪 **Playtester-Programm** | `/playtester-setup` postet den Bewerbungs-Button; Rolle + Liste (Config-Seite); Alpha-Keys später per DM über die API | | ⭐ **Starboard** | Nachrichten mit genug ⭐-Reaktionen landen automatisch im Best-of-Kanal | @@ -55,7 +55,7 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St | ⚡ **Auto-Deploy** | Push auf `main` → Portainer rollt neu aus; optional mit Code-Prüfung über Gitea Actions — [Anleitung](docs/auto-deploy.md) | | 🏠 **Portal** | Dienste (Gitea, Kanban, Cloud …) in der Config pflegen → Kacheln auf der Startseite und Links in der geteilten Navigation | | ⚖️ **Rechtstexte** | `/impressum` + `/datenschutz` in der Config gepflegt; Member können ihre Daten selbst löschen (DSGVO Art. 17) | -| 💡 **Feature-Voting** | `/wunsch` → Voting-Post mit 👍; Top-Wünsche öffentlich auf der Roadmap-Seite | +| 💡 **Feature-Voting** | `/wunsch` öffnet ein Eingabefenster (Idee + „warum wäre das gut?") → Voting-Post mit 👍; Top-Wünsche öffentlich auf der Roadmap-Seite | | 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung nach Ablauf; am Gewinner-Post: 🔁 Neu auslosen (Admin) + 👥 Teilnehmerliste | | 📈 **Contribution-Heatmap** | GitHub-Style-Jahreskalender aus dem Commit-Archiv auf der Roadmap-Seite | | 👤 **Member-Bereich** | Discord-Login für alle Member: `/profil` (Rang + XP-Balken, Rollen, Playtester-Badge, eigener Alpha-Key), Rollen-Selfservice im Browser (nutzt die Rollen-Menüs), Wünsche einreichen + upvoten auf der Roadmap; **Member-Gate**: Login nur für Server-Mitglieder (abweisbare bekommen den Invite-Link) | @@ -95,6 +95,17 @@ Definiert sind sie zentral in [`src/modules.js`](src/modules.js). Funktionen mit einem bereits vorhandenen Schalter (Level-System, Backups …) nutzen weiterhin dieselbe Einstellung — es gibt also keine zweite Wahrheit. +### Werte + +Zahlen, die das Verhalten steuern — XP pro Nachricht, Wartezeiten, +Prüf-Intervalle, Postzeiten, Obergrenzen — stehen im Tab *Werte* mit erlaubtem +Bereich und Standard. Geänderte Intervalle greifen ab dem nächsten Durchlauf, +ohne Neustart. + +Definiert sind sie in [`src/tuning.js`](src/tuning.js). Gespeichert wird ganz +oder gar nicht: ein ungültiger Wert lässt die anderen im selben Formular +unverändert. + ### Texte Was der Bot nach außen schreibt — Begrüßungen, Bestätigungen, Direktnachrichten — diff --git a/src/bot/client.js b/src/bot/client.js index 1ff36a3..1a1759d 100644 --- a/src/bot/client.js +++ b/src/bot/client.js @@ -155,6 +155,21 @@ export async function startBot() { return; } + // Eingabefenster von /bug und /wunsch + if (interaction.isModalSubmit() && (interaction.customId === 'bugmodal' || interaction.customId === 'wunschmodal')) { + const handler = interaction.customId === 'bugmodal' ? bug.handleModal : wunsch.handleModal; + try { + await handler(interaction); + } catch (error) { + console.error(`[${interaction.customId}] Fehler:`, error); + const meldung = { content: '❌ Da ist etwas schiefgelaufen.', flags: MessageFlags.Ephemeral }; + await (interaction.deferred || interaction.replied + ? interaction.editReply(meldung.content) + : interaction.reply(meldung)).catch(() => {}); + } + return; + } + // Bewerbungen: Button → Modal → Review-Entscheidung try { if (interaction.isButton() && interaction.customId.startsWith('apply:')) { diff --git a/src/bot/commands/bug.js b/src/bot/commands/bug.js index 754192e..909acab 100644 --- a/src/bot/commands/bug.js +++ b/src/bot/commands/bug.js @@ -1,5 +1,14 @@ -// /bug — Bug-Report aus Discord: erstellt ein Gitea-Issue (inkl. Screenshot-Upload) -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; +// /bug — Bug-Report aus Discord: öffnet ein Eingabefenster und legt daraus ein +// Gitea-Issue an (inkl. Screenshot-Upload). +// +// Warum ein Modal statt Slash-Optionen: In der Befehlszeile tippt man eine +// Beschreibung einzeilig und ohne Umbrüche. Das Fenster bietet mehrzeilige +// Felder, getrennt nach „was ist passiert" und „was war erwartet" — und die +// Meldungen werden dadurch spürbar brauchbarer. +import { + SlashCommandBuilder, MessageFlags, + ModalBuilder, ActionRowBuilder, TextInputBuilder, TextInputStyle, +} from 'discord.js'; import { bugReportRepo, giteaApiToken } from '../../runtime-settings.js'; import { createIssue, uploadIssueAsset } from '../../gitea-api.js'; @@ -9,48 +18,98 @@ import { renderTemplate } from '../../templates.js'; export const data = new SlashCommandBuilder() .setName('bug') .setDescription('Bug melden — landet direkt beim Entwickler') - .addStringOption((o) => - o.setName('titel').setDescription('Kurze Zusammenfassung').setRequired(true).setMaxLength(120) - ) - .addStringOption((o) => - o.setName('beschreibung') - .setDescription('Was ist passiert? Was hast du erwartet?') - .setRequired(true) - .setMaxLength(1500) - ) .addAttachmentOption((o) => o.setName('screenshot').setDescription('Optional: Screenshot des Problems') ); -export async function execute(interaction) { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); +// Der Screenshot hängt an der Befehls-Interaktion, das Formular kommt aber als +// eigene Interaktion zurück — deshalb kurz zwischenparken. +const pendingScreenshot = new Map(); // userId → { url, name, at } +const SCREENSHOT_TTL_MS = 20 * 60 * 1000; +function stashScreenshot(userId, attachment) { + // Abgelaufene Einträge nebenbei aufräumen, damit die Map nicht wächst + for (const [id, entry] of pendingScreenshot) { + if (Date.now() - entry.at > SCREENSHOT_TTL_MS) pendingScreenshot.delete(id); + } + if (attachment?.contentType?.startsWith('image/')) { + pendingScreenshot.set(userId, { + url: attachment.url, + name: attachment.name ?? 'screenshot.png', + at: Date.now(), + }); + } else { + pendingScreenshot.delete(userId); + } +} + +export async function execute(interaction) { if (!giteaApiToken()) { - await interaction.editReply('❌ Bug-Reports sind gerade nicht konfiguriert (kein Gitea-Token).'); + await interaction.reply({ + content: '❌ Bug-Reports sind gerade nicht konfiguriert (kein Gitea-Token).', + flags: MessageFlags.Ephemeral, + }); return; } - const repo = bugReportRepo(); - const titel = interaction.options.getString('titel'); - const beschreibung = interaction.options.getString('beschreibung'); - const screenshot = interaction.options.getAttachment('screenshot'); + stashScreenshot(interaction.user.id, interaction.options.getAttachment('screenshot')); - const body = `${beschreibung}\n\n---\n🎮 Gemeldet via Discord von **${interaction.user.tag}**`; + const modal = new ModalBuilder().setCustomId('bugmodal').setTitle('Bug melden'); + modal.addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('titel').setLabel('Kurz gesagt: was ist kaputt?') + .setPlaceholder('z. B. Inventar öffnet sich nicht nach dem Autoausstieg') + .setStyle(TextInputStyle.Short).setMaxLength(120).setRequired(true) + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('was').setLabel('Was ist passiert?') + .setStyle(TextInputStyle.Paragraph).setMaxLength(1200).setRequired(true) + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('erwartet').setLabel('Was hättest du erwartet?') + .setStyle(TextInputStyle.Paragraph).setMaxLength(600).setRequired(false) + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('schritte').setLabel('Wie kann man es nachstellen?') + .setPlaceholder('1. …\n2. …\n3. …') + .setStyle(TextInputStyle.Paragraph).setMaxLength(800).setRequired(false) + ) + ); + await interaction.showModal(modal); +} + +/** Formular abgeschickt → Issue anlegen */ +export async function handleModal(interaction) { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const repo = bugReportRepo(); + const titel = interaction.fields.getTextInputValue('titel'); + const was = interaction.fields.getTextInputValue('was'); + const erwartet = interaction.fields.getTextInputValue('erwartet')?.trim(); + const schritte = interaction.fields.getTextInputValue('schritte')?.trim(); + + const body = [ + was, + erwartet ? `\n**Erwartet:**\n${erwartet}` : '', + schritte ? `\n**Nachstellen:**\n${schritte}` : '', + `\n---\n🎮 Gemeldet via Discord von **${interaction.user.tag}**`, + ].filter(Boolean).join('\n'); try { const issue = await createIssue(repo, `[Bug] ${titel}`, body); - // Screenshot als Issue-Attachment hochladen (Discord-CDN-Links laufen ab) - if (screenshot?.contentType?.startsWith('image/')) { + // Screenshot als Issue-Anhang hochladen (Discord-CDN-Links laufen ab) + const shot = pendingScreenshot.get(interaction.user.id); + pendingScreenshot.delete(interaction.user.id); + if (shot) { try { - const res = await fetch(screenshot.url); + const res = await fetch(shot.url); if (res.ok) { - await uploadIssueAsset( - repo, - issue.number, - screenshot.name ?? 'screenshot.png', - Buffer.from(await res.arrayBuffer()) - ); + await uploadIssueAsset(repo, issue.number, shot.name, Buffer.from(await res.arrayBuffer())); } } catch (error) { console.error('[bug] Screenshot-Upload fehlgeschlagen:', error.message); diff --git a/src/bot/commands/wunsch.js b/src/bot/commands/wunsch.js index 7e5de98..5e7cf2f 100644 --- a/src/bot/commands/wunsch.js +++ b/src/bot/commands/wunsch.js @@ -1,17 +1,50 @@ -// /wunsch — Feature-Wunsch einreichen: Voting-Post mit 👍, Rangliste auf der Webseite -import { SlashCommandBuilder, MessageFlags, EmbedBuilder } from 'discord.js'; +// /wunsch — Feature-Wunsch einreichen: Eingabefenster → Voting-Post mit 👍, +// Rangliste auf der Webseite. +// +// Das Fenster statt einer Slash-Option, weil eine Idee selten in eine Zeile +// passt — und weil die Rückfrage „warum wäre das gut?" die Vorschläge +// deutlich besser macht als ein Einzeiler. +import { + SlashCommandBuilder, MessageFlags, EmbedBuilder, + ModalBuilder, ActionRowBuilder, TextInputBuilder, TextInputStyle, +} from 'discord.js'; import { votingChannelId, publicUrl, brandColor, brandFooter } from '../../runtime-settings.js'; import { saveWish } from '../../db.js'; import { renderTemplate } from '../../templates.js'; export const data = new SlashCommandBuilder() .setName('wunsch') - .setDescription('Feature-Wunsch für EcoGame einreichen — die Community stimmt ab') - .addStringOption((o) => - o.setName('idee').setDescription('Dein Vorschlag').setRequired(true).setMaxLength(500) - ); + .setDescription('Feature-Wunsch einreichen — die Community stimmt ab'); export async function execute(interaction) { + if (!votingChannelId()) { + await interaction.reply({ + content: '❌ Feature-Voting ist gerade nicht aktiviert.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const modal = new ModalBuilder().setCustomId('wunschmodal').setTitle('Feature-Wunsch'); + modal.addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('idee').setLabel('Deine Idee') + .setPlaceholder('Was soll dazukommen oder anders laufen?') + .setStyle(TextInputStyle.Paragraph).setMaxLength(500).setRequired(true) + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('warum').setLabel('Warum wäre das gut?') + .setPlaceholder('Was wird dadurch besser oder einfacher?') + .setStyle(TextInputStyle.Paragraph).setMaxLength(400).setRequired(false) + ) + ); + await interaction.showModal(modal); +} + +/** Formular abgeschickt → Voting-Post */ +export async function handleModal(interaction) { const channelId = votingChannelId(); if (!channelId) { await interaction.reply({ @@ -22,7 +55,8 @@ export async function execute(interaction) { } await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - const idea = interaction.options.getString('idee'); + const idee = interaction.fields.getTextInputValue('idee'); + const warum = interaction.fields.getTextInputValue('warum')?.trim(); const channel = await interaction.client.channels.fetch(channelId); const embed = new EmbedBuilder() @@ -32,13 +66,13 @@ export async function execute(interaction) { iconURL: interaction.user.displayAvatarURL({ size: 64 }), }) .setTitle('💡 Feature-Wunsch') - .setDescription(idea) + .setDescription(warum ? `${idee}\n\n**Warum:** ${warum}` : idee) .setURL(`${publicUrl()}/roadmap`) .setFooter({ text: `${brandFooter('VOTING')} • 👍 = will ich!` }); const message = await channel.send({ embeds: [embed] }); await message.react('👍'); - saveWish(message.id, interaction.user.username, idea); + saveWish(message.id, interaction.user.username, idee); await interaction.editReply(renderTemplate('wish.submitted', { user: interaction.member?.displayName ?? interaction.user.username,