Emojis zum Anklicken, und Wuensche als Forums-Beitraege mit Tags
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

Ueberall, wo ein Emoji nach Discord geht — Rollen-Menues, Ticket-Anliegen,
Wunsch-Bereiche, Portal-Kacheln — stand ein leeres Textfeld, in das man mit
Win+. hineintippen musste. Jetzt sitzt daneben ein Knopf mit einer Tafel:
sieben Gruppen, deutsche Suchbegriffe ("auto", "geld", "warnung"), ein Klick
schreibt ins Feld. Das Feld bleibt daneben stehen, denn Server-Emojis
(<:name:123>) kennt nur Discord.

Bewusst eine eigene Liste statt einer Bibliothek: die vollstaendige
Unicode-Tabelle waere ein halbes Megabyte fuer eine Handvoll Symbole, und die
Suchbegriffe duerfen deutsch sein.

## Forum-Tags

Ist der Voting-Kanal ein Forum, wird jeder Wunsch jetzt ein Forums-Beitrag
statt Nachricht-plus-Thread — und bekommt Tags fuer Bereich und Stand. Damit
laesst sich auch in Discord filtern, wofuer Tags ja da sind. Beim Wechsel des
Stands zieht das Tag mit: der alte fliegt raus, der neue kommt rein, der
Bereich bleibt stehen.

Zugeordnet wird ueber den Namen. Wer im Forum ein Tag "Fahrzeuge" anlegt,
bekommt es automatisch gesetzt; wer keines anlegt, verliert nichts. Bewusst
keine ID-Zuordnung im Panel — das waere eine zweite Liste, die man pflegen
muss und die beim ersten Umbenennen auseinanderlaeuft.

Dabei aufgefallen und mitgefixt: im Forum liegt die Startnachricht im Beitrag
selbst und nicht im Kanal. Der Status-Aktualisierer hat sie vorher im Kanal
gesucht und nicht gefunden — die Begruendung waere im Forum also nie im Post
gelandet. Und der Titel wird jetzt nur um den Stand ergaenzt statt
ueberschrieben, damit "🚗 Fahrzeuge" stehen bleibt.

Geprueft: tagsFuer gegen 20 Faelle — kein Forum, Medien-Kanal, fehlende Tags,
Gross/Kleinschreibung, Deckel bei fuenf (mehr nimmt Discord nicht), und alle
fuenf Staende einzeln. Im Browser die Emoji-Tafel an allen vier Stellen:
Suche, Escape, Klick daneben, Leeren, Uebernehmen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 23:45:18 +02:00
co-authored by Claude Opus 5
parent edf8fa14c5
commit cc7a51f02e
7 changed files with 366 additions and 26 deletions
+10
View File
@@ -29,6 +29,16 @@ export async function execute(interaction) {
.setFooter({ text: brandFooter('VOTING') });
const anzahl = listWishCategories().length;
// In einem Forum-Kanal selbst kann man nichts posten — der Aufruf gehört
// in einen normalen Kanal, die Wünsche landen trotzdem im Forum
if (!interaction.channel?.send) {
await interaction.reply({
content: '❌ Hier lässt sich nichts posten. Führ den Befehl in einem normalen Textkanal aus — '
+ 'die Ideen landen trotzdem im eingestellten Voting-Kanal.',
flags: MessageFlags.Ephemeral,
});
return;
}
await interaction.channel.send({ embeds: [embed], components: wunschPanelComponents() });
await interaction.reply({
content: anzahl > 0
+85 -3
View File
@@ -10,7 +10,7 @@
// Unterhaltung nur zerreißen. Die Roadmap zeigt die Zahl der Beiträge und
// verlinkt hinein.
import {
ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, Events,
ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, EmbedBuilder, Events,
ModalBuilder, MessageFlags, StringSelectMenuBuilder, TextInputBuilder, TextInputStyle,
} from 'discord.js';
import {
@@ -21,6 +21,22 @@ import { renderTemplate } from '../templates.js';
import { tuning } from '../tuning.js';
import { alsEmoji } from './tickets.js';
/**
* Der Stand eines Wunsches, wie er nach außen heißt. Steht hier und nicht in
* der API, weil ihn beides braucht: das Panel zum Anzeigen und der Bot für
* Embed-Titel und Forum-Tags.
*/
export const STATUS_LABEL = {
offen: 'Wird geprüft',
geplant: 'Geplant',
in_arbeit: 'In Arbeit',
umgesetzt: 'Umgesetzt',
abgelehnt: 'Nicht geplant',
};
export const STATUS_EMOJI = {
offen: '💡', geplant: '📌', in_arbeit: '🔨', umgesetzt: '✅', abgelehnt: '🚫',
};
/** Knopf oder Auswahlmenü für den Aufruf-Post — je nachdem, ob es Bereiche gibt */
export function wunschPanelComponents() {
const bereiche = listWishCategories();
@@ -67,15 +83,41 @@ export function wunschModal(categoryId = null) {
);
}
/** Ist der Voting-Kanal ein Forum? Dann wird jeder Wunsch ein Forums-Beitrag. */
const istForum = (channel) =>
channel?.type === ChannelType.GuildForum || channel?.type === ChannelType.GuildMedia;
/**
* Passende Forum-Tags zu Bereich und Stand heraussuchen — über den Namen.
* Wer im Forum ein Tag „Fahrzeuge" anlegt, bekommt es automatisch gesetzt;
* wer keines anlegt, verliert nichts. Deshalb bewusst keine ID-Zuordnung im
* Panel: das wäre eine zweite Liste, die man pflegen muss.
*/
export function tagsFuer(channel, { bereich, statusLabel }) {
if (!istForum(channel)) return [];
const gesucht = [bereich?.name, statusLabel].filter(Boolean).map((s) => s.toLowerCase());
return (channel.availableTags ?? [])
.filter((t) => gesucht.includes(t.name.toLowerCase()))
.map((t) => t.id)
.slice(0, 5); // Discord erlaubt höchstens fünf je Beitrag
}
/**
* Wunsch posten, speichern und den Thread aufmachen.
*
* Im Forum ist der Beitrag selbst schon der Thread — dort wird er mit Tags für
* Bereich und Stand angelegt, damit man auch in Discord filtern kann. In einem
* normalen Textkanal bleibt es bei Nachricht + Thread darunter.
*
* @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.');
if (!channel?.isTextBased() && !istForum(channel)) {
throw new Error('Voting-Kanal nicht gefunden.');
}
const bereich = categoryId ? getWishCategory(categoryId) : null;
const kopf = bereich ? `${bereich.emoji || '💡'} ${bereich.name}` : '💡 Feature-Wunsch';
@@ -87,6 +129,22 @@ export async function wunschAnlegen(client, { idee, warum, autor, autorId, avata
.setURL(`${publicUrl()}/roadmap`)
.setFooter({ text: `${brandFooter('VOTING')} • 👍 = will ich!` });
const archiv = tuning('thread_archive_days') * 1440;
if (istForum(channel)) {
const thread = await channel.threads.create({
name: idee.slice(0, 100),
autoArchiveDuration: archiv,
appliedTags: tagsFuer(channel, { bereich, statusLabel: STATUS_LABEL.offen }),
message: { embeds: [embed] },
});
const starter = await thread.fetchStarterMessage().catch(() => null);
await starter?.react('👍').catch(() => {});
const id = saveWish(starter?.id ?? thread.id, autor, idee, autorId ?? null, categoryId ?? null);
setWishThread(id, thread.id);
return { id, url: `${thread.url}` };
}
const message = await channel.send({ embeds: [embed] });
await message.react('👍').catch(() => {});
const id = saveWish(message.id, autor, idee, autorId ?? null, categoryId ?? null);
@@ -96,7 +154,7 @@ export async function wunschAnlegen(client, { idee, warum, autor, autorId, avata
try {
const thread = await message.startThread({
name: `💬 ${idee}`.slice(0, 100),
autoArchiveDuration: tuning('thread_archive_days') * 1440,
autoArchiveDuration: archiv,
});
setWishThread(id, thread.id);
} catch (error) {
@@ -105,6 +163,30 @@ export async function wunschAnlegen(client, { idee, warum, autor, autorId, avata
return { id, url: message.url };
}
/**
* Nach einem Status-Wechsel die Tags am Forums-Beitrag nachziehen: der alte
* Stand fliegt raus, der neue kommt rein, der Bereich bleibt.
*/
export async function wunschTagsAktualisieren(client, wunsch, statusLabel) {
if (!wunsch.thread_id) return;
const channelId = votingChannelId();
if (!channelId) return;
const channel = await client.channels.fetch(channelId).catch(() => null);
if (!istForum(channel)) return;
const thread = await client.channels.fetch(wunsch.thread_id).catch(() => null);
if (!thread?.setAppliedTags) return;
const alleStaende = new Set(Object.values(STATUS_LABEL).map((s) => s.toLowerCase()));
const behalten = (thread.appliedTags ?? []).filter((id) => {
const tag = (channel.availableTags ?? []).find((t) => t.id === id);
return tag && !alleStaende.has(tag.name.toLowerCase());
});
const neu = tagsFuer(channel, { bereich: null, statusLabel });
await thread.setAppliedTags([...new Set([...behalten, ...neu])].slice(0, 5)).catch((e) =>
console.error('[voting] Tags nicht gesetzt:', e.message)
);
}
/** Auswahl im Aufruf-Post → Eingabefenster für diesen Bereich */
export async function handleWunschPick(interaction) {
const gewaehlt = Number(interaction.values?.[0]);
+27 -15
View File
@@ -34,7 +34,9 @@ import { tuningStates, setTuning, tuning, TUNING_GROUPS } from '../tuning.js';
import { lastResults, GAME_ICONS } from '../bot/server-monitor.js';
import { watchdogState, watchedServices } from '../bot/watchdog.js';
import { listRules, setRuleEnabled } from '../bot/automod.js';
import { wunschAnlegen } from '../bot/wishes.js';
import {
wunschAnlegen, wunschTagsAktualisieren, STATUS_LABEL, STATUS_EMOJI,
} from '../bot/wishes.js';
import { heartbeatSerie } from '../bot/heartbeat.js';
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
import { sanitizeEmbed } from './api-v1.js';
@@ -2025,13 +2027,9 @@ ${rssItems}
// --- Wünsche verwalten (Team) ---
const STATUS = {
offen: { label: 'Wird geprüft', emoji: '💡' },
geplant: { label: 'Geplant', emoji: '📌' },
in_arbeit: { label: 'In Arbeit', emoji: '🔨' },
umgesetzt: { label: 'Umgesetzt', emoji: '✅' },
abgelehnt: { label: 'Nicht geplant', emoji: '🚫' },
};
const STATUS = Object.fromEntries(Object.entries(STATUS_LABEL).map(([id, label]) => [
id, { label, emoji: STATUS_EMOJI[id] },
]));
/**
* Den Wunsch-Post in Discord auf den neuen Stand bringen. Dort haben die
@@ -2042,16 +2040,29 @@ ${rssItems}
if (!wunsch.message_id) return;
const channelId = votingChannelId();
if (!channelId) return;
const channel = await client.channels.fetch(channelId).catch(() => null);
const message = await channel?.messages?.fetch(wunsch.message_id).catch(() => null);
// Im Forum liegt die Startnachricht im Beitrag selbst, nicht im Kanal —
// deshalb zuerst dort suchen, wo der Wunsch seinen Thread hat
const wo = wunsch.thread_id
? await client.channels.fetch(wunsch.thread_id).catch(() => null)
: null;
const channel = wo ?? await client.channels.fetch(channelId).catch(() => null);
let message = await channel?.messages?.fetch(wunsch.message_id).catch(() => null);
if (!message && wo) {
// Textkanal mit Thread darunter: die Nachricht steht eine Ebene höher
const eltern = await client.channels.fetch(channelId).catch(() => null);
message = await eltern?.messages?.fetch(wunsch.message_id).catch(() => null);
}
if (!message?.embeds?.[0]) return;
const s = STATUS[wunsch.status] ?? STATUS.offen;
const embed = EmbedBuilder.from(message.embeds[0])
.setTitle(`${s.emoji} Feature-Wunsch — ${s.label}`);
const felder = wunsch.status_grund
const alt = message.embeds[0];
// Den Kopf nur um den Stand ergänzen, nicht den Bereich überschreiben —
// dort steht „🚗 Fahrzeuge", das soll bleiben
const basis = String(alt.title ?? 'Feature-Wunsch').split(' — ')[0];
const embed = EmbedBuilder.from(alt).setTitle(`${basis}${s.emoji} ${s.label}`);
embed.setFields(wunsch.status_grund
? [{ name: 'Vom Team', value: String(wunsch.status_grund).slice(0, 1000) }]
: [];
embed.setFields(felder);
: []);
await message.edit({ embeds: [embed] }).catch(() => {});
}
@@ -2066,6 +2077,7 @@ ${rssItems}
const wunsch = getWish(id);
await wunschPostAktualisieren(wunsch);
await wunschTagsAktualisieren(client, wunsch, STATUS[status].label);
// Kreis schliessen: wer dafür gestimmt hat, erfährt es — aber nur beim
// Sprung auf umgesetzt, und nur einmal (vorher war ein anderer Status)