/bug und /wunsch als Eingabefenster
Beide Befehle nahmen ihre Eingaben als Slash-Optionen entgegen. Das heißt: eine Fehlerbeschreibung einzeilig in die Befehlszeile tippen, ohne Umbrüche, ohne Absätze. Entsprechend dürftig fielen die Meldungen aus. /bug öffnet jetzt ein Fenster mit vier Feldern: Titel, was ist passiert, was war erwartet, wie kann man es nachstellen. Die letzten beiden sind freiwillig und erzeugen im Issue nur dann eine Überschrift, wenn sie ausgefüllt sind. /wunsch fragt die Idee und ein optionales „warum wäre das gut?" ab — die Rückfrage macht aus einem Einzeiler einen Vorschlag, über den man abstimmen kann. Der Screenshot bleibt eine Option am Befehl, weil Discord in Fenstern keine Dateien erlaubt. Er hängt damit an der Befehls-Interaktion, das Formular kommt aber als eigene zurück — der Anhang wird deshalb kurz zwischengeparkt und nach dem Absenden verwendet. Abgelaufene Einträge räumt der nächste Aufruf mit weg, damit da nichts liegen bleibt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 —
|
||||
|
||||
@@ -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:')) {
|
||||
|
||||
+87
-28
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user