Screenshots zu Ideen, und der Bot legt die Forum-Tags selbst an
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

Zwei Fragen, zwei Antworten — eine davon ein Nein mit Umweg.

## Bilder im Eingabefenster: geht nicht

Discord-Modals koennen nur Textfelder. Kein Datei-Anhang, keine Umgehung — das
ist eine Grenze der Plattform, nicht unseres Codes.

Der Umweg ist besser als er klingt: Wer nach dem Einreichen einen Screenshot in
den Thread haengt, sieht ihn auf der Ideen-Seite. Die Bestaetigung nach dem
Absenden sagt das jetzt auch. Damit kann man auch nachtraeglich noch Bilder
dazulegen, was im Eingabefenster gar nicht ginge.

## Warum nicht einfach der Discord-Link

Discord haengt seit einiger Zeit eine Signatur an die Anhang-Links
(?ex=&is=&hm=), die nach etwa einem Tag ablaeuft. Ein gespeicherter Link zeigt
morgen ein kaputtes Bild. Deshalb laden Devlogs und Galerie seit dem ersten Tag
herunter — im Kopf von devlog-archive.js steht der Grund. Wuensche machen es
jetzt genauso, ausgeliefert unter /wish-assets/.

Beim Loeschen eines Beitrags oder eines ganzen Wunsches gehen die Dateien mit;
sonst waechst der Ordner mit Bildern, auf die nichts mehr zeigt.

Das Herunterladen stand vorher zweimal fast wortgleich im Code. Mit den
Wuenschen waeren es drei Kopien geworden — also einmal nach bot/bilder.js, und
Devlogs und Galerie ziehen mit um. Ein Fehler steckt jetzt an einer Stelle
statt an dreien.

## Forum-Tags per Knopf

Bisher musstest du sie von Hand anlegen. Jetzt macht es das Panel: die Bereiche
von dort plus die fuenf Staende, mit ihren Emojis. Vorhandene bleiben, auch
fremde — die Liste wird ergaenzt und nicht ersetzt. Discords Grenze von 20 Tags
wird eingehalten, was nicht mehr reinpasst, wird gemeldet statt still
verschluckt.

Geprueft: forumTagsAnlegen gegen eine Kanal-Attrappe — leeres Forum, teilweise
vorhandene, Gross/Kleinschreibung, fremde Tags, nichts zu tun (dann wird auch
nicht geschrieben), volles Forum, kein Forum, kein Kanal. Im Browser die
Ideen-Seite mit Bildern in einem Beitrag, einem Beitrag nur aus Bild ohne Text,
und der Lightbox darueber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 00:17:01 +02:00
co-authored by Claude Opus 5
parent 5073e7bcf4
commit b3758f1f76
12 changed files with 237 additions and 74 deletions
+54
View File
@@ -0,0 +1,54 @@
// Bilder aus Discord-Nachrichten lokal sichern.
//
// Discord-CDN-Links laufen ab — wer sie nur speichert, hat nach ein paar Wochen
// eine Seite voller kaputter Vorschaubilder. Deshalb wird heruntergeladen und
// vom Webserver selbst ausgeliefert.
//
// Stand vorher dreimal fast gleich im Code (Devlogs, Galerie, jetzt Wünsche);
// hier einmal, damit ein Fehler auch nur einmal drinsteckt.
import { mkdirSync } from 'node:fs';
import { writeFile, rm } from 'node:fs/promises';
import { join, dirname, resolve } from 'node:path';
import { config } from '../config.js';
/** Ordner neben der SQLite anlegen und zurückgeben */
export function bildOrdner(name) {
const dir = join(dirname(resolve(config.dbPath)), name);
mkdirSync(dir, { recursive: true });
return dir;
}
/**
* Bild-Anhänge einer Nachricht herunterladen.
*
* @param {import('discord.js').Message} message
* @param {{ dir: string, max?: number, tag?: string }} opts
* @returns {Promise<string[]>} gespeicherte Dateinamen
*/
export async function bilderSichern(message, { dir, max = 4, tag = 'bilder' }) {
const files = [];
for (const att of [...(message.attachments?.values?.() ?? [])]) {
if (files.length >= max) break;
if (!att.contentType?.startsWith('image/')) continue;
// Endung säubern: der Name kommt vom Hochladenden und landet im Dateisystem
const ext = (att.name?.split('.').pop() || 'png').toLowerCase().replace(/[^a-z0-9]/g, '') || 'png';
const file = `${message.id}_${files.length}.${ext}`;
try {
const res = await fetch(att.url);
if (!res.ok) continue;
await writeFile(join(dir, file), Buffer.from(await res.arrayBuffer()));
files.push(file);
} catch (error) {
console.error(`[${tag}] Bild-Download fehlgeschlagen (${att.url}):`, error.message);
}
}
return files;
}
/** Gespeicherte Bilder wieder wegräumen */
export async function bilderLoeschen(dir, files) {
for (const file of files ?? []) {
await rm(join(dir, file), { force: true });
}
}
+6 -24
View File
@@ -1,7 +1,4 @@
// Community-Features: Starboard (⭐ → Best-of-Kanal) + Screenshot-Galerie
import { mkdirSync } from 'node:fs';
import { writeFile, rm } from 'node:fs/promises';
import { join, dirname, resolve } from 'node:path';
import { EmbedBuilder, Events } from 'discord.js';
import {
hasStarboard, saveStarboard,
@@ -10,12 +7,11 @@ import {
import { starboardChannelId, starboardThreshold, screenshotChannelId, brandColor } from '../runtime-settings.js';
import { moduleEnabled } from '../modules.js';
import { tuning } from '../tuning.js';
import { config } from '../config.js';
import { bilderSichern, bilderLoeschen, bildOrdner } from './bilder.js';
// Galerie-Bilder liegen neben der SQLite: data/gallery/
export const galleryDir = join(dirname(resolve(config.dbPath)), 'gallery');
mkdirSync(galleryDir, { recursive: true });
export const galleryDir = bildOrdner('gallery');
/* ── Screenshot-Galerie ────────────────────────────── */
@@ -27,21 +23,9 @@ export async function archiveGalleryMessage(message) {
if (!moduleEnabled('gallery')) return;
if (hasGalleryItem(message.id)) return false;
const files = [];
for (const att of message.attachments?.values?.() ?? []) {
if (files.length >= tuning('gallery_max_images')) break;
if (!att.contentType?.startsWith('image/')) continue;
const ext = (att.name?.split('.').pop() || 'png').toLowerCase().replace(/[^a-z0-9]/g, '') || 'png';
const file = `${message.id}_${files.length}.${ext}`;
try {
const res = await fetch(att.url);
if (!res.ok) continue;
await writeFile(join(galleryDir, file), Buffer.from(await res.arrayBuffer()));
files.push(file);
} catch (error) {
console.error(`[galerie] Bild-Download fehlgeschlagen: ${error.message}`);
}
}
const files = await bilderSichern(message, {
dir: galleryDir, max: tuning('gallery_max_images'), tag: 'galerie',
});
if (files.length === 0) return false;
return saveGalleryItem({
@@ -55,9 +39,7 @@ export async function archiveGalleryMessage(message) {
/** Galerie-Eintrag inkl. Bild-Dateien entfernen */
export async function removeGalleryMessage(messageId) {
const { deleted, images } = deleteGalleryItem(messageId);
for (const file of images) {
await rm(join(galleryDir, file), { force: true });
}
await bilderLoeschen(galleryDir, images);
return deleted;
}
+5 -29
View File
@@ -2,17 +2,13 @@
// Bilder werden lokal gespeichert (Discord-CDN-Links laufen ab!) und vom
// Webserver unter /devlog-assets/ ausgeliefert.
// Wird vom Live-Listener (MessageCreate) und vom /devlog-backfill-Command genutzt.
import { mkdirSync } from 'node:fs';
import { writeFile, rm } from 'node:fs/promises';
import { join, dirname, resolve } from 'node:path';
import { saveDevlog, hasDevlog, deleteDevlog } from '../db.js';
import { config } from '../config.js';
import { bilderSichern, bilderLoeschen, bildOrdner } from './bilder.js';
const MAX_IMAGES = 4;
// Bilder liegen neben der SQLite: data/devlog_images/
export const imagesDir = join(dirname(resolve(config.dbPath)), 'devlog_images');
mkdirSync(imagesDir, { recursive: true });
export const imagesDir = bildOrdner('devlog_images');
/** Text aus einer Nachricht ziehen — Plain-Content plus Embed-Titel/-Beschreibungen */
function extractContent(message) {
@@ -28,26 +24,8 @@ function extractContent(message) {
}
/** Bild-Attachments herunterladen, gespeicherte Dateinamen zurückgeben */
async function downloadImages(message) {
const files = [];
const attachments = [...(message.attachments?.values?.() ?? [])];
for (const att of attachments) {
if (files.length >= MAX_IMAGES) break;
if (!att.contentType?.startsWith('image/')) continue;
const ext = (att.name?.split('.').pop() || 'png').toLowerCase().replace(/[^a-z0-9]/g, '') || 'png';
const file = `${message.id}_${files.length}.${ext}`;
try {
const res = await fetch(att.url);
if (!res.ok) continue;
await writeFile(join(imagesDir, file), Buffer.from(await res.arrayBuffer()));
files.push(file);
} catch (error) {
console.error(`[devlog] Bild-Download fehlgeschlagen (${att.url}):`, error.message);
}
}
return files;
}
const downloadImages = (message) =>
bilderSichern(message, { dir: imagesDir, max: MAX_IMAGES, tag: 'devlog' });
/**
* Devlog aus dem Archiv entfernen (inkl. lokal gespeicherter Bilder).
@@ -55,9 +33,7 @@ async function downloadImages(message) {
*/
export async function removeDevlog(messageId) {
const { deleted, images } = deleteDevlog(messageId);
for (const file of images) {
await rm(join(imagesDir, file), { force: true });
}
await bilderLoeschen(imagesDir, images);
return deleted;
}
+61 -7
View File
@@ -15,13 +15,17 @@ import {
} from 'discord.js';
import {
saveWish, setWishThread, wishByThread, getWishCategory, listWishCategories,
saveWishComment, updateWishComment, deleteWishComment,
saveWishComment, updateWishComment, deleteWishComment, wishCommentImagesOf,
} from '../db.js';
import { bilderSichern, bilderLoeschen, bildOrdner } from './bilder.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';
// Bilder aus den Threads liegen neben der SQLite, wie bei Devlogs und Galerie
export const wunschBilderDir = bildOrdner('wish_images');
/**
* 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
@@ -164,6 +168,48 @@ export async function wunschAnlegen(client, { idee, warum, autor, autorId, avata
return { id, url: message.url };
}
/**
* Fehlende Tags im Forum anlegen — die Bereiche und die fünf Stände.
*
* Discord erlaubt 20 Tags je Forum. Vorhandene bleiben unangetastet, auch
* fremde: die Liste wird ergänzt, nicht ersetzt.
*
* @returns {Promise<{angelegt: string[], schonDa: string[], keinPlatz: string[]}>}
*/
export async function forumTagsAnlegen(client) {
const channelId = votingChannelId();
if (!channelId) throw new Error('Kein Voting-Kanal eingestellt.');
const channel = await client.channels.fetch(channelId).catch(() => null);
if (!istForum(channel)) throw new Error('Der Voting-Kanal ist kein Forum.');
const vorhanden = channel.availableTags ?? [];
const kennt = new Set(vorhanden.map((t) => t.name.toLowerCase()));
// Bereiche zuerst — die Stände sind ohne sie immer noch nützlich, umgekehrt
// sortiert sich ohne Bereiche nichts
const gewuenscht = [
...listWishCategories().map((b) => ({ name: b.name, emoji: alsEmoji(b.emoji) })),
...Object.entries(STATUS_LABEL).map(([id, name]) => ({
name, emoji: alsEmoji(STATUS_EMOJI[id]),
})),
];
const angelegt = [];
const schonDa = [];
const keinPlatz = [];
const neu = [...vorhanden];
for (const t of gewuenscht) {
if (kennt.has(t.name.toLowerCase())) { schonDa.push(t.name); continue; }
if (neu.length >= 20) { keinPlatz.push(t.name); continue; }
neu.push({ name: t.name.slice(0, 20), emoji: t.emoji, moderated: false });
kennt.add(t.name.toLowerCase());
angelegt.push(t.name);
}
if (angelegt.length > 0) await channel.setAvailableTags(neu);
return { angelegt, schonDa, keinPlatz };
}
/**
* Nach einem Status-Wechsel die Tags am Forums-Beitrag nachziehen: der alte
* Stand fliegt raus, der neue kommt rein, der Bereich bleibt.
@@ -232,21 +278,28 @@ export async function handleWunschModal(interaction) {
* immer, was in Discord längst zurückgenommen wurde.
*/
export function registerWishThreads(client) {
client.on(Events.MessageCreate, (message) => {
client.on(Events.MessageCreate, async (message) => {
try {
if (message.author?.bot || !message.channel?.isThread?.()) return;
const wunsch = wishByThread(message.channelId);
if (!wunsch) return;
const text = message.content?.trim();
// Reine Bild-Posts haben keinen Text — die zeigt die Seite nicht
if (!text) return;
const hatBilder = [...(message.attachments?.values?.() ?? [])]
.some((a) => a.contentType?.startsWith('image/'));
// Ohne Text und ohne Bild gibt es nichts zu zeigen
if (!text && !hatBilder) return;
// Discord-Links laufen ab — deshalb herunterladen statt verweisen
const bilder = hatBilder
? await bilderSichern(message, { dir: wunschBilderDir, max: 4, tag: 'voting' })
: [];
saveWishComment({
message_id: message.id,
wish_id: wunsch.id,
author: message.member?.displayName ?? message.author.username,
author_id: message.author.id,
avatar: message.author.displayAvatarURL?.({ size: 64 }) ?? null,
content: text.slice(0, 2000),
content: (text ?? '').slice(0, 2000),
images: JSON.stringify(bilder),
created_at: new Date(message.createdTimestamp).toISOString().slice(0, 19).replace('T', ' '),
});
} catch (error) {
@@ -264,9 +317,10 @@ export function registerWishThreads(client) {
}
});
client.on(Events.MessageDelete, (message) => {
client.on(Events.MessageDelete, async (message) => {
try {
deleteWishComment(message.id);
const bilder = wishCommentImagesOf(message.id);
if (deleteWishComment(message.id)) await bilderLoeschen(wunschBilderDir, bilder);
} catch (error) {
console.error('[voting] Kommentar nicht entfernt:', error);
}
+18 -3
View File
@@ -326,14 +326,21 @@ db.exec(`
author_id TEXT,
avatar TEXT,
content TEXT NOT NULL,
images TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_wish_comments ON wish_comments (wish_id, created_at);
`);
{
const spalten = db.prepare('PRAGMA table_info(wish_comments)').all().map((c) => c.name);
if (!spalten.includes('images')) {
db.exec(`ALTER TABLE wish_comments ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`);
}
}
const insertCommentStmt = db.prepare(`
INSERT OR REPLACE INTO wish_comments
(message_id, wish_id, author, author_id, avatar, content, created_at)
VALUES (@message_id, @wish_id, @author, @author_id, @avatar, @content, @created_at)
(message_id, wish_id, author, author_id, avatar, content, images, created_at)
VALUES (@message_id, @wish_id, @author, @author_id, @avatar, @content, @images, @created_at)
`);
const updateCommentStmt = db.prepare('UPDATE wish_comments SET content = ? WHERE message_id = ?');
const deleteCommentStmt = db.prepare('DELETE FROM wish_comments WHERE message_id = ?');
@@ -344,7 +351,15 @@ const dropCommentsStmt = db.prepare('DELETE FROM wish_comments WHERE wish_id = ?
export const saveWishComment = (c) => insertCommentStmt.run(c);
export const updateWishComment = (messageId, text) => updateCommentStmt.run(text, messageId).changes > 0;
export const deleteWishComment = (messageId) => deleteCommentStmt.run(messageId).changes > 0;
export const wishComments = (wishId, limit = 200) => listCommentsStmt.all(wishId, limit);
export const wishComments = (wishId, limit = 200) =>
listCommentsStmt.all(wishId, limit).map((c) => ({ ...c, images: JSON.parse(c.images || '[]') }));
/** Alle Bilddateien eines Wunsches — zum Wegräumen beim Löschen */
const wishImagesStmt = db.prepare('SELECT images FROM wish_comments WHERE wish_id = ?');
export const wishCommentImages = (wishId) =>
wishImagesStmt.all(wishId).flatMap((r) => JSON.parse(r.images || '[]'));
const commentImagesStmt = db.prepare('SELECT images FROM wish_comments WHERE message_id = ?');
export const wishCommentImagesOf = (messageId) =>
JSON.parse(commentImagesStmt.get(messageId)?.images || '[]');
const listWishCatsStmt = db.prepare('SELECT * FROM wish_categories ORDER BY sort, id');
const getWishCatStmt = db.prepare('SELECT * FROM wish_categories WHERE id = ?');
+2 -1
View File
@@ -188,7 +188,8 @@ export const TEMPLATES = [
id: 'wish.submitted', group: 'sonstiges', module: 'voting',
name: 'Feature-Wunsch — Bestätigung', long: true,
vars: [...WHO, { key: 'url', desc: 'Link zum Voting-Post' }],
default: '✅ Wunsch eingereicht — [zur Abstimmung]({url})!',
default: '✅ Wunsch eingereicht — [zur Abstimmung]({url})!\n'
+ 'Screenshot dazu? Häng ihn einfach in den Thread — er landet mit auf der Ideen-Seite.',
},
{
id: 'wish.panel_title', group: 'sonstiges', module: 'voting',
+20 -2
View File
@@ -16,7 +16,7 @@ import {
saveTemplate, listTemplates, deleteTemplate,
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
getWish, searchWishes, setWishStatus, mergeWish, deleteWish, wishVoters, setWishCategory,
wishComments,
wishComments, wishCommentImages,
listWishCategories, createWishCategory, updateWishCategory, deleteWishCategory,
logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, watchdogDays,
heartbeatBuckets, heartbeatFirst,
@@ -36,8 +36,10 @@ 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, wunschTagsAktualisieren, STATUS_LABEL, STATUS_EMOJI,
wunschAnlegen, wunschTagsAktualisieren, forumTagsAnlegen, wunschBilderDir,
STATUS_LABEL, STATUS_EMOJI,
} from '../bot/wishes.js';
import { bilderLoeschen } from '../bot/bilder.js';
import { heartbeatSerie } from '../bot/heartbeat.js';
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
import { sanitizeEmbed } from './api-v1.js';
@@ -2058,6 +2060,19 @@ ${rssItems}
return { deleted: weg, bereiche: listWishCategories(), wishes: topWishes(50) };
});
// Fehlende Tags im Forum anlegen — spart das Abtippen der Bereichsnamen
// und der fuenf Staende, und Tippfehler waeren hier stumme Fehlschlaege
app.post('/api/wish-categories/tags', async (request, reply) => {
if (requireScope(request, reply, 'community')) return;
try {
const res = await forumTagsAnlegen(client);
logAudit(getSessionUser(request), 'forum-tags angelegt', res.angelegt.join(', ') || 'keine');
return { ok: true, ...res };
} catch (error) {
return reply.code(400).send({ error: error.message });
}
});
// --- Wünsche verwalten (Team) ---
const STATUS = Object.fromEntries(Object.entries(STATUS_LABEL).map(([id, label]) => [
@@ -2162,7 +2177,10 @@ ${rssItems}
if (requireScope(request, reply, 'community')) return;
const wunsch = getWish(Number(request.params.id));
if (!wunsch) return { deleted: false, wishes: topWishes(50) };
// Erst die Dateinamen holen, dann loeschen — danach sind sie weg
const bilder = wishCommentImages(wunsch.id);
deleteWish(wunsch.id);
await bilderLoeschen(wunschBilderDir, bilder);
logAudit(getSessionUser(request), 'wunsch gelöscht', String(wunsch.idea).slice(0, 60));
return { deleted: true, wishes: topWishes(50) };
});
+10
View File
@@ -95,6 +95,16 @@ export async function startWebServer(client) {
immutable: true,
});
// Bilder aus Wunsch-Threads
const { wunschBilderDir } = await import('../bot/wishes.js');
await app.register(fastifyStatic, {
root: wunschBilderDir,
prefix: '/wish-assets/',
decorateReply: false,
maxAge: '30d',
immutable: true,
});
if (existsSync(frontendDist)) {
// React-Frontend ausliefern; unbekannte GET-Pfade → index.html (SPA-Routing).
// Dabei werden Open-Graph-Tags injiziert, damit geteilte Links (Discord,