Ideen bekommen Bereiche und einen Thread zum Reden
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

Nach dem Vorbild von windrose.support: Bereiche zum Einsortieren, Sortierung
nach Top / Bewegung / Neu, und eine Zahl daneben, wie viel darueber geredet
wird.

Der Unterschied zu so einem Dienst ist, wo geredet wird. Zu jedem Wunsch macht
der Bot jetzt einen Thread unter dem Post auf. Der Kommentar-Zaehler auf der
Roadmap zaehlt die Beitraege darin und verlinkt hinein — es gibt also einen
Zaehler wie bei einem Ideen-Board, aber ohne ein zweites Kommentarsystem neben
Discord zu stellen. Genau das hatte ich beim letzten Mal als das Falsche
bezeichnet, und dabei bleibt es.

Eingereicht wird jetzt auch ueber einen Aufruf-Post: /wunsch-setup postet ihn,
und mit Bereichen wird daraus ein Auswahlmenue — erst wohin, dann das
Formular. Ohne Bereiche bleibt es beim einen Knopf. Derselbe Aufbau wie bei den
Ticket-Anliegen, bis hin zur Emoji-Pruefung, die ich mir dort schon geschrieben
hatte.

Das Anlegen selbst ist nach bot/wishes.js gewandert. /wunsch, der Knopf und die
Webseite gehen jetzt denselben Weg — vorher hatte die Webseite ihr eigenes
Embed zusammengebaut, das dem aus dem Befehl nur aehnlich sah und keinen Thread
bekam.

"Bewegung" sind die Stimmen der letzten sieben Tage. Die Rangliste allein
zementiert alte Wuensche: was einmal oben steht, bleibt oben, egal ob noch
jemand hinschaut. Sortiert wird auf dem Server, weil die Zeitstempel der
Stimmen im Browser gar nicht ankommen.

Bewusst nicht uebernommen: Gegenstimmen. Windrose hat sie, aber bei einer
Community dieser Groesse laden sie zum Nachtreten ein und schrecken vom
Einreichen ab — und eine Zahl, die aus zwei Richtungen kommt, sagt am Ende
weniger als eine, die nur zaehlt, wer etwas will.

Geprueft: Migration erneut durchgespielt (das Schema hat drei Spalten
dazubekommen), Routen, SQL gegen das Schema, und im Browser alle drei
Filterreihen einzeln und kombiniert, der Sortierwechsel, die Thread-Links und
die Bereichs-Pflege im Panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 23:31:55 +02:00
co-authored by Claude Opus 5
parent fb61e7279d
commit edf8fa14c5
13 changed files with 657 additions and 128 deletions
+22 -4
View File
@@ -20,6 +20,9 @@ import {
claimTicketButton, unclaimTicketButton,
} from './tickets.js';
import { handleRoleMenuButton } from './role-menus.js';
import {
handleWunschPick, handleWunschButton, handleWunschModal, registerWishThreads,
} from './wishes.js';
import { handleGiveawayAdminButton } from './giveaways.js';
import {
removePlaytester, playtesterForm, wishByMessage, addWishVote, removeWishVote,
@@ -31,6 +34,7 @@ import * as bug from './commands/bug.js';
import * as playtesterSetup from './commands/playtester-setup.js';
import * as galerieBackfill from './commands/galerie-backfill.js';
import * as wunsch from './commands/wunsch.js';
import * as wunschSetup from './commands/wunsch-setup.js';
import * as giveaway from './commands/giveaway.js';
import * as ticketSetup from './commands/ticket-setup.js';
import * as warn from './commands/warn.js';
@@ -47,7 +51,7 @@ import * as umfrage from './commands/umfrage.js';
// Alle Commands hier eintragen — jedes Modul exportiert { data, execute }
const commandModules = [
ping, devlogBackfill, bug, playtesterSetup, galerieBackfill,
wunsch, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag, remind, geburtstag,
wunsch, wunschSetup, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag, remind, geburtstag,
raid, umfrage,
];
@@ -196,9 +200,22 @@ 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;
// Ideen einreichen: Knopf oder Auswahl im Aufruf-Post -> Eingabefenster
if ((interaction.isButton() && interaction.customId === 'wunsch_neu')
|| (interaction.isStringSelectMenu() && interaction.customId === 'wunsch_pick')) {
try {
if (interaction.isButton()) await handleWunschButton(interaction);
else await handleWunschPick(interaction);
} catch (error) {
console.error('[voting] Eingabefenster fehlgeschlagen:', error);
}
return;
}
// Eingabefenster von /bug und /wunsch (der Bereich haengt hinter dem Doppelpunkt)
if (interaction.isModalSubmit()
&& (interaction.customId === 'bugmodal' || interaction.customId.startsWith('wunschmodal'))) {
const handler = interaction.customId === 'bugmodal' ? bug.handleModal : handleWunschModal;
try {
await handler(interaction);
} catch (error) {
@@ -374,6 +391,7 @@ export async function startBot() {
// Vor den anderen Beitritts-Handlern egal — die Erkennung zaehlt nur mit
registerAntiRaid(client);
registerAutoMod(client);
registerWishThreads(client);
registerLevels(client);
registerPresence(client);
registerExtras(client);
+39
View File
@@ -0,0 +1,39 @@
// /wunsch-setup — postet den Ideen-Aufruf in den aktuellen Kanal (Admin).
//
// Gibt es Bereiche, wird daraus ein Auswahlmenü: erst wohin, dann das
// Eingabefenster. Ohne Bereiche bleibt es beim einen Knopf.
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, EmbedBuilder } from 'discord.js';
import { votingChannelId, brandColor, brandFooter } from '../../runtime-settings.js';
import { renderTemplate } from '../../templates.js';
import { listWishCategories } from '../../db.js';
import { wunschPanelComponents } from '../wishes.js';
export const data = new SlashCommandBuilder()
.setName('wunsch-setup')
.setDescription('Postet den Ideen-Aufruf mit Knopf in diesen Kanal')
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator);
export async function execute(interaction) {
if (!votingChannelId()) {
await interaction.reply({
content: '❌ Erst in der Config einen Voting-Kanal wählen.',
flags: MessageFlags.Ephemeral,
});
return;
}
const embed = new EmbedBuilder()
.setColor(brandColor())
.setTitle(renderTemplate('wish.panel_title'))
.setDescription(renderTemplate('wish.panel_text'))
.setFooter({ text: brandFooter('VOTING') });
const anzahl = listWishCategories().length;
await interaction.channel.send({ embeds: [embed], components: wunschPanelComponents() });
await interaction.reply({
content: anzahl > 0
? `✅ Aufruf gepostet — mit Auswahl aus ${anzahl} Bereichen.`
: '✅ Aufruf gepostet. Bereiche zum Einsortieren legst du in der Config unter Community an.',
flags: MessageFlags.Ephemeral,
});
}
+30 -64
View File
@@ -1,21 +1,40 @@
// /wunsch — Feature-Wunsch einreichen: Eingabefenster → Voting-Post mit 👍,
// Rangliste auf der Webseite.
// Thread zum Reden, 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';
//
// Das Posten selbst steckt in bot/wishes.js, damit Befehl, Knopf unter dem
// Aufruf-Post und Webseite denselben Weg gehen und ein Wunsch überall gleich
// aussieht.
import { SlashCommandBuilder, MessageFlags } from 'discord.js';
import { votingChannelId } from '../../runtime-settings.js';
import { listWishCategories } from '../../db.js';
import { wunschModal } from '../wishes.js';
export const data = new SlashCommandBuilder()
const builder = new SlashCommandBuilder()
.setName('wunsch')
.setDescription('Feature-Wunsch einreichen — die Community stimmt ab');
// Discord will die Auswahlmöglichkeiten schon beim Registrieren kennen. Wer
// Bereiche nachträglich anlegt, bekommt sie hier beim nächsten Bot-Start —
// über den Aufruf-Post (/wunsch-setup) stehen sie sofort zur Verfügung.
{
const bereiche = listWishCategories();
if (bereiche.length > 0) {
builder.addStringOption((option) => option
.setName('bereich')
.setDescription('Worum geht es?')
.addChoices(...bereiche.slice(0, 25).map((b) => ({
name: `${b.emoji} ${b.name}`.trim().slice(0, 100),
value: String(b.id),
}))));
}
}
export const data = builder;
export async function execute(interaction) {
if (!votingChannelId()) {
await interaction.reply({
@@ -24,59 +43,6 @@ export async function execute(interaction) {
});
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({
content: '❌ Feature-Voting ist gerade nicht aktiviert.',
flags: MessageFlags.Ephemeral,
});
return;
}
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
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()
.setColor(brandColor())
.setAuthor({
name: interaction.member?.displayName ?? interaction.user.username,
iconURL: interaction.user.displayAvatarURL({ size: 64 }),
})
.setTitle('💡 Feature-Wunsch')
.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, idee);
await interaction.editReply(renderTemplate('wish.submitted', {
user: interaction.member?.displayName ?? interaction.user.username,
mention: `<@${interaction.user.id}>`,
url: message.url,
}));
const gewaehlt = Number(interaction.options.getString('bereich'));
await interaction.showModal(wunschModal(Number.isFinite(gewaehlt) ? gewaehlt : null));
}
+154
View File
@@ -0,0 +1,154 @@
// Feature-Wünsche: einreichen, posten, bereden.
//
// Ein Wunsch entsteht an drei Stellen — per /wunsch, über den Knopf unter dem
// Aufruf-Post und auf der Webseite. Alle drei landen hier, damit ein Wunsch
// überall gleich aussieht: gebrandetes Embed, 👍 zum Abstimmen, und ein Thread
// darunter fürs Reden.
//
// Der Thread ist bewusst der Diskussionsort und nicht ein Kommentarfeld auf der
// Webseite: die Leute sind ohnehin in Discord, und ein zweiter Ort würde die
// Unterhaltung nur zerreißen. Die Roadmap zeigt die Zahl der Beiträge und
// verlinkt hinein.
import {
ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, Events,
ModalBuilder, MessageFlags, StringSelectMenuBuilder, TextInputBuilder, TextInputStyle,
} from 'discord.js';
import {
saveWish, setWishThread, bumpWishKommentare, getWishCategory, listWishCategories,
} from '../db.js';
import { votingChannelId, publicUrl, brandColor, brandFooter } from '../runtime-settings.js';
import { renderTemplate } from '../templates.js';
import { tuning } from '../tuning.js';
import { alsEmoji } from './tickets.js';
/** Knopf oder Auswahlmenü für den Aufruf-Post — je nachdem, ob es Bereiche gibt */
export function wunschPanelComponents() {
const bereiche = listWishCategories();
if (bereiche.length === 0) {
return [new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('wunsch_neu')
.setStyle(ButtonStyle.Primary)
.setLabel('Idee einreichen')
.setEmoji('💡')
)];
}
const menu = new StringSelectMenuBuilder()
.setCustomId('wunsch_pick')
.setPlaceholder('Worum geht es?')
.addOptions(bereiche.slice(0, 25).map((b) => {
const option = { label: b.name.slice(0, 100), value: String(b.id) };
const emoji = alsEmoji(b.emoji);
if (emoji) option.emoji = emoji;
return option;
}));
return [new ActionRowBuilder().addComponents(menu)];
}
/** Das Eingabefenster. Der Bereich reist in der Kennung mit. */
export function wunschModal(categoryId = null) {
const bereich = categoryId ? getWishCategory(categoryId) : null;
return new ModalBuilder()
.setCustomId(`wunschmodal:${categoryId ?? ''}`)
.setTitle(bereich ? `Idee — ${bereich.name}`.slice(0, 45) : 'Feature-Wunsch')
.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)
)
);
}
/**
* Wunsch posten, speichern und den Thread aufmachen.
* @returns {Promise<{id: number, url: string}>}
*/
export async function wunschAnlegen(client, { idee, warum, autor, autorId, avatar, categoryId }) {
const channelId = votingChannelId();
if (!channelId) throw new Error('Feature-Voting ist gerade nicht aktiviert.');
const channel = await client.channels.fetch(channelId);
if (!channel?.isTextBased()) throw new Error('Voting-Kanal nicht gefunden.');
const bereich = categoryId ? getWishCategory(categoryId) : null;
const kopf = bereich ? `${bereich.emoji || '💡'} ${bereich.name}` : '💡 Feature-Wunsch';
const embed = new EmbedBuilder()
.setColor(brandColor())
.setAuthor({ name: autor, iconURL: avatar || undefined })
.setTitle(kopf)
.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('👍').catch(() => {});
const id = saveWish(message.id, autor, idee, autorId ?? null, categoryId ?? null);
// Thread zum Reden. Scheitert er (fehlende Rechte, kein Thread-fähiger
// Kanal), bleibt der Wunsch trotzdem stehen — abstimmen geht auch ohne.
try {
const thread = await message.startThread({
name: `💬 ${idee}`.slice(0, 100),
autoArchiveDuration: tuning('thread_archive_days') * 1440,
});
setWishThread(id, thread.id);
} catch (error) {
console.error('[voting] Thread nicht angelegt:', error.message);
}
return { id, url: message.url };
}
/** Auswahl im Aufruf-Post → Eingabefenster für diesen Bereich */
export async function handleWunschPick(interaction) {
const gewaehlt = Number(interaction.values?.[0]);
await interaction.showModal(wunschModal(Number.isFinite(gewaehlt) ? gewaehlt : null));
}
/** Knopf ohne Bereiche → Eingabefenster ohne Bereich */
export async function handleWunschButton(interaction) {
await interaction.showModal(wunschModal(null));
}
/** Formular abgeschickt → Wunsch anlegen */
export async function handleWunschModal(interaction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const rohBereich = interaction.customId.split(':')[1];
const categoryId = rohBereich ? Number(rohBereich) : null;
const name = interaction.member?.displayName ?? interaction.user.username;
try {
const { url } = await wunschAnlegen(interaction.client, {
idee: interaction.fields.getTextInputValue('idee'),
warum: interaction.fields.getTextInputValue('warum')?.trim(),
autor: name,
autorId: interaction.user.id,
avatar: interaction.user.displayAvatarURL({ size: 64 }),
categoryId,
});
await interaction.editReply(renderTemplate('wish.submitted', {
user: name,
mention: `<@${interaction.user.id}>`,
url,
}));
} catch (error) {
await interaction.editReply(`${error.message}`).catch(() => {});
}
}
/** Beiträge in Wunsch-Threads mitzählen — das ist der Kommentar-Zähler */
export function registerWishThreads(client) {
client.on(Events.MessageCreate, (message) => {
try {
if (message.author?.bot || !message.channel?.isThread?.()) return;
bumpWishKommentare(message.channelId);
} catch (error) {
console.error('[voting] Kommentar-Zähler:', error);
}
});
}