Community-Paket: Playtester-Programm, Starboard, Screenshot-Galerie

- Playtester: /playtester-setup postet Bewerbungs-Embed mit 🧪-Button;
  Toggle vergibt Rolle + DB-Eintrag; Liste + Rollen-Auswahl auf der Setup-Seite
- Starboard: ab konfigurierbarem -Schwellwert Repost als Embed in den
  Best-of-Kanal (Dedupe, kein Selbst-Boarding); neue Intents/Partials für Reaktionen
- Galerie: Bilder aus dem Screenshot-Kanal lokal archiviert (live + /galerie-backfill,
  Delete-Sync, Bot reagiert mit ) → öffentliche /galerie-Seite mit Hover-Grid
- Setup-Seite: Sektion // Community (Rolle, Starboard-Kanal + Schwellwert, Screenshot-Kanal)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 12:34:34 +02:00
co-authored by Claude Opus 4.8
parent 6d8f383aed
commit a3c47656c5
13 changed files with 563 additions and 12 deletions
+41 -4
View File
@@ -1,14 +1,18 @@
// Discord-Client: Commands laden, registrieren und Interactions verarbeiten
import { Client, Collection, Events, GatewayIntentBits, MessageFlags, Partials, REST, Routes } from 'discord.js';
import { config } from '../config.js';
import { devlogChannelId, devlogPingRoleId } from '../runtime-settings.js';
import { devlogChannelId, devlogPingRoleId, playtesterRoleId } from '../runtime-settings.js';
import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js';
import { registerCommunityListeners } from './community.js';
import { addPlaytester, removePlaytester } from '../db.js';
import * as ping from './commands/ping.js';
import * as devlogBackfill from './commands/devlog-backfill.js';
import * as bug from './commands/bug.js';
import * as playtesterSetup from './commands/playtester-setup.js';
import * as galerieBackfill from './commands/galerie-backfill.js';
// Alle Commands hier eintragen — jedes Modul exportiert { data, execute }
const commandModules = [ping, devlogBackfill, bug];
const commandModules = [ping, devlogBackfill, bug, playtesterSetup, galerieBackfill];
export async function startBot() {
const client = new Client({
@@ -18,9 +22,11 @@ export async function startBot() {
// (erfordert aktivierten "Message Content Intent" im Developer Portal!)
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
// Für das Starboard (⭐-Reaktionen)
GatewayIntentBits.GuildMessageReactions,
],
// Partials, damit MessageDelete auch für ungecachte (ältere) Nachrichten feuert
partials: [Partials.Message],
// Partials, damit Delete/Reaction-Events auch für ungecachte Nachrichten feuern
partials: [Partials.Message, Partials.Reaction],
});
// Commands in Collection ablegen für schnellen Zugriff im Interaction-Handler
@@ -91,6 +97,35 @@ export async function startBot() {
return;
}
// 🧪-Button: Playtester-Rolle + Liste togglen
if (interaction.isButton() && interaction.customId === 'playtester_toggle') {
const roleId = playtesterRoleId();
try {
if (!roleId || !interaction.inGuild()) throw new Error('Keine Playtester-Rolle konfiguriert');
const member = interaction.member;
const hasRole = member.roles.cache.has(roleId);
if (hasRole) {
await member.roles.remove(roleId);
removePlaytester(interaction.user.id);
} else {
await member.roles.add(roleId);
addPlaytester(interaction.user.id, interaction.user.username);
}
await interaction.reply({
content: hasRole
? '🚪 Von der Playtester-Liste **abgemeldet**.'
: '🧪 **Willkommen im Playtest-Team!** Du stehst auf der Liste — sobald es losgeht, meldet sich der Bot per DM.',
flags: MessageFlags.Ephemeral,
});
} catch (error) {
console.error('[playtester] Toggle fehlgeschlagen:', error);
await interaction
.reply({ content: '❌ Konnte die Rolle nicht ändern — Rechte/Rollen-Reihenfolge prüfen.', flags: MessageFlags.Ephemeral })
.catch(() => {});
}
return;
}
if (!interaction.isChatInputCommand()) return;
const command = client.commands.get(interaction.commandName);
@@ -109,6 +144,8 @@ export async function startBot() {
}
});
registerCommunityListeners(client);
await client.login(config.discordToken);
return client;
}
+41
View File
@@ -0,0 +1,41 @@
// /galerie-backfill — bestehende Screenshots aus dem Kanal nacharchivieren (Admin)
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js';
import { screenshotChannelId } from '../../runtime-settings.js';
import { archiveGalleryMessage } from '../community.js';
export const data = new SlashCommandBuilder()
.setName('galerie-backfill')
.setDescription('Archiviert alle bisherigen Screenshots aus dem Galerie-Kanal')
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator);
export async function execute(interaction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const channelId = screenshotChannelId();
if (!channelId) {
await interaction.editReply('❌ Erst auf der Setup-Seite einen Screenshot-Kanal wählen.');
return;
}
const channel = await interaction.client.channels.fetch(channelId);
if (!channel?.isTextBased()) {
await interaction.editReply('❌ Screenshot-Kanal nicht gefunden.');
return;
}
let scanned = 0;
let saved = 0;
let before;
for (;;) {
const batch = await channel.messages.fetch({ limit: 100, before });
if (batch.size === 0) break;
for (const message of batch.values()) {
scanned++;
if (await archiveGalleryMessage(message)) saved++;
}
before = batch.last().id;
}
await interaction.editReply(
`✅ Galerie-Backfill fertig: ${scanned} Nachrichten gescannt, **${saved} Screenshots archiviert**.`
);
}
+44
View File
@@ -0,0 +1,44 @@
// /playtester-setup — postet das Bewerbungs-Embed mit Button in den aktuellen Kanal (Admin)
import {
SlashCommandBuilder, PermissionFlagsBits, MessageFlags,
ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder,
} from 'discord.js';
import { playtesterRoleId } from '../../runtime-settings.js';
export const data = new SlashCommandBuilder()
.setName('playtester-setup')
.setDescription('Postet das Playtester-Bewerbungs-Embed in diesen Kanal')
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator);
export async function execute(interaction) {
if (!playtesterRoleId()) {
await interaction.reply({
content: '❌ Erst auf der Setup-Seite eine Playtester-Rolle wählen.',
flags: MessageFlags.Ephemeral,
});
return;
}
const embed = new EmbedBuilder()
.setColor(0xf5c518)
.setTitle('🧪 Playtester werden')
.setDescription(
'Du willst **EcoGame** vor allen anderen spielen und mit deinem Feedback ' +
'die Entwicklung mitgestalten?\n\n' +
'Ein Klick auf den Button setzt dich auf die Playtester-Liste — ' +
'sobald die Alpha startet, bekommst du deinen Zugang direkt per DM. ' +
'Abmelden geht jederzeit mit einem zweiten Klick.'
)
.setFooter({ text: 'D4RKST3R // PLAYTEST' });
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('playtester_toggle')
.setStyle(ButtonStyle.Success)
.setLabel('Als Playtester bewerben')
.setEmoji('🧪')
);
await interaction.channel.send({ embeds: [embed], components: [row] });
await interaction.reply({ content: '✅ Bewerbungs-Post erstellt.', flags: MessageFlags.Ephemeral });
}
+135
View File
@@ -0,0 +1,135 @@
// 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,
saveGalleryItem, hasGalleryItem, deleteGalleryItem,
} from '../db.js';
import { starboardChannelId, starboardThreshold, screenshotChannelId } from '../runtime-settings.js';
import { config } from '../config.js';
const BRAND_YELLOW = 0xf5c518;
const MAX_GALLERY_IMAGES = 4;
// Galerie-Bilder liegen neben der SQLite: data/gallery/
export const galleryDir = join(dirname(resolve(config.dbPath)), 'gallery');
mkdirSync(galleryDir, { recursive: true });
/* ── Screenshot-Galerie ────────────────────────────── */
/**
* Nachricht aus dem Screenshot-Kanal archivieren (Bilder lokal speichern).
* @returns {Promise<boolean>} true, wenn neu gespeichert
*/
export async function archiveGalleryMessage(message) {
if (hasGalleryItem(message.id)) return false;
const files = [];
for (const att of message.attachments?.values?.() ?? []) {
if (files.length >= MAX_GALLERY_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}`);
}
}
if (files.length === 0) return false;
return saveGalleryItem({
message_id: message.id,
author_name: message.member?.displayName ?? message.author?.username ?? 'unbekannt',
images: JSON.stringify(files),
posted_at: message.createdAt.toISOString(),
});
}
/** 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 });
}
return deleted;
}
/* ── Starboard ─────────────────────────────────────── */
async function handleStarReaction(reaction) {
const boardChannelId = starboardChannelId();
if (!boardChannelId) return;
if (reaction.emoji.name !== '⭐') return;
if (reaction.partial) await reaction.fetch();
let message = reaction.message;
if (message.partial) message = await message.fetch();
// Nicht das Starboard selbst boarden, Dedupe, Schwellwert
if (message.channelId === boardChannelId) return;
if (hasStarboard(message.id)) return;
if ((reaction.count ?? 0) < starboardThreshold()) return;
const board = await message.client.channels.fetch(boardChannelId);
if (!board?.isTextBased()) return;
const image = [...message.attachments.values()].find((a) =>
a.contentType?.startsWith('image/')
);
const embed = new EmbedBuilder()
.setColor(BRAND_YELLOW)
.setAuthor({
name: message.member?.displayName ?? message.author?.username ?? 'unbekannt',
iconURL: message.author?.displayAvatarURL?.() ?? undefined,
})
.setDescription(
`${(message.content || '').slice(0, 1800) || '*[Bild]*'}\n\n[→ Zum Original](${message.url})`
)
.setFooter({ text: `${reaction.count} • #${message.channel?.name ?? ''}` })
.setTimestamp(message.createdAt);
if (image) embed.setImage(image.url);
const boardMessage = await board.send({ embeds: [embed] });
saveStarboard(message.id, boardMessage.id);
console.log(`[starboard] ${message.id} geboardet (${reaction.count} ⭐)`);
}
/* ── Listener-Registrierung ────────────────────────── */
export function registerCommunityListeners(client) {
client.on(Events.MessageReactionAdd, async (reaction) => {
try {
await handleStarReaction(reaction);
} catch (error) {
console.error('[starboard] Fehler:', error);
}
});
client.on(Events.MessageCreate, async (message) => {
if (message.channelId !== screenshotChannelId()) return;
try {
if (await archiveGalleryMessage(message)) {
console.log(`[galerie] Screenshot archiviert (${message.id})`);
await message.react('⭐').catch(() => {});
}
} catch (error) {
console.error('[galerie] Archivierung fehlgeschlagen:', error);
}
});
client.on(Events.MessageDelete, async (message) => {
try {
if (await removeGalleryMessage(message.id)) {
console.log(`[galerie] Eintrag entfernt (${message.id})`);
}
} catch (error) {
console.error('[galerie] Löschung fehlgeschlagen:', error);
}
});
}