Screenshots zu Ideen, und der Bot legt die Forum-Tags selbst an
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:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user