Feature-Voting, Giveaways und Contribution-Heatmap

- /wunsch: Voting-Embed mit 👍, Reaction-Tracking (add/remove) in SQLite,
  Top-20-Rangliste öffentlich auf der Roadmap-Seite
- /giveaway (Admin): Preis/Dauer/Gewinnerzahl, 🎉-Teilnahme-Button (toggle),
  Minuten-Scheduler zieht Gewinner, schließt das Embed ab und pingt sie
- Contribution-Heatmap: 52-Wochen-Grid in Brand-Gelb aus dem Commit-Archiv
  (/api/heatmap) auf der Roadmap-Seite
- Setting voting_channel_id auf der Setup-Seite

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 13:20:44 +02:00
co-authored by Claude Opus 4.8
parent 075ea36bd2
commit bc000aaa9f
12 changed files with 391 additions and 4 deletions
+35 -2
View File
@@ -5,15 +5,17 @@ import { devlogChannelId, devlogPingRoleId, playtesterRoleId } from '../runtime-
import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js';
import { registerCommunityListeners } from './community.js';
import { registerModTools } from './mod-tools.js';
import { addPlaytester, removePlaytester } from '../db.js';
import { addPlaytester, removePlaytester, isWish, bumpWish, getGiveaway, toggleGiveawayEntry, giveawayEntries } 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';
import * as wunsch from './commands/wunsch.js';
import * as giveaway from './commands/giveaway.js';
// Alle Commands hier eintragen — jedes Modul exportiert { data, execute }
const commandModules = [ping, devlogBackfill, bug, playtesterSetup, galerieBackfill];
const commandModules = [ping, devlogBackfill, bug, playtesterSetup, galerieBackfill, wunsch, giveaway];
export async function startBot() {
const client = new Client({
@@ -57,6 +59,19 @@ export async function startBot() {
}
});
// 👍-Reaktionen auf Feature-Wünsche zählen (für die Rangliste auf der Webseite)
const trackWishReaction = (delta) => async (reaction) => {
try {
if (reaction.emoji.name !== '👍') return;
if (reaction.partial) await reaction.fetch().catch(() => {});
if (isWish(reaction.message.id)) bumpWish(reaction.message.id, delta);
} catch (error) {
console.error('[voting] Reaction-Tracking fehlgeschlagen:', error);
}
};
client.on(Events.MessageReactionAdd, trackWishReaction(1));
client.on(Events.MessageReactionRemove, trackWishReaction(-1));
// In Discord gelöscht = im Archiv gelöscht (inkl. lokaler Bilder)
client.on(Events.MessageDelete, async (message) => {
if (message.channelId !== devlogChannelId()) return;
@@ -102,6 +117,24 @@ export async function startBot() {
return;
}
// 🎉-Button: Giveaway-Teilnahme togglen
if (interaction.isButton() && interaction.customId === 'giveaway_enter') {
const g = getGiveaway(interaction.message.id);
if (!g || g.ended) {
await interaction.reply({ content: '❌ Dieses Giveaway ist schon vorbei.', flags: MessageFlags.Ephemeral }).catch(() => {});
return;
}
const entered = toggleGiveawayEntry(g.message_id, interaction.user.id);
const count = giveawayEntries(g.message_id).length;
await interaction.reply({
content: entered
? `🎉 Du bist dabei! (${count} Teilnahmen)`
: `🚪 Teilnahme zurückgezogen. (${count} Teilnahmen)`,
flags: MessageFlags.Ephemeral,
}).catch(() => {});
return;
}
// 🧪-Button: Playtester-Rolle + Liste togglen
if (interaction.isButton() && interaction.customId === 'playtester_toggle') {
const roleId = playtesterRoleId();
+62
View File
@@ -0,0 +1,62 @@
// /giveaway — Verlosung starten (Admin): 🎉-Button zum Teilnehmen, automatische Ziehung
import {
SlashCommandBuilder, PermissionFlagsBits, MessageFlags,
ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder,
} from 'discord.js';
import { saveGiveaway } from '../../db.js';
/** "30m" / "2h" / "1d" → Millisekunden (null bei Unsinn) */
export function parseDuration(input) {
const m = String(input).trim().match(/^(\d+)\s*(m|h|d)$/i);
if (!m) return null;
const factor = { m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2].toLowerCase()];
const ms = Number(m[1]) * factor;
return ms >= 60_000 && ms <= 30 * 86_400_000 ? ms : null;
}
export const data = new SlashCommandBuilder()
.setName('giveaway')
.setDescription('Verlosung in diesem Kanal starten')
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.addStringOption((o) => o.setName('preis').setDescription('Was gibt es zu gewinnen?').setRequired(true).setMaxLength(200))
.addStringOption((o) => o.setName('dauer').setDescription('z. B. 30m, 2h, 1d').setRequired(true))
.addIntegerOption((o) => o.setName('gewinner').setDescription('Anzahl Gewinner (Default 1)').setMinValue(1).setMaxValue(20));
export async function execute(interaction) {
const duration = parseDuration(interaction.options.getString('dauer'));
if (!duration) {
await interaction.reply({
content: '❌ Dauer bitte als `30m`, `2h` oder `1d` (1 Minute bis 30 Tage).',
flags: MessageFlags.Ephemeral,
});
return;
}
const prize = interaction.options.getString('preis');
const winners = interaction.options.getInteger('gewinner') ?? 1;
const endsAt = new Date(Date.now() + duration);
const embed = new EmbedBuilder()
.setColor(0xf5c518)
.setTitle(`🎉 Giveaway: ${prize}`)
.setDescription(
`Klick auf den Button zum Teilnehmen!\n\n` +
`🏆 **${winners}** Gewinner · ⏰ Ziehung <t:${Math.floor(endsAt.getTime() / 1000)}:R>`
)
.setFooter({ text: 'D4RKST3R // GIVEAWAY' })
.setTimestamp(endsAt);
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder().setCustomId('giveaway_enter').setStyle(ButtonStyle.Primary).setLabel('Teilnehmen').setEmoji('🎉')
);
const message = await interaction.channel.send({ embeds: [embed], components: [row] });
saveGiveaway({
message_id: message.id,
channel_id: message.channelId,
prize,
winners,
// SQLite vergleicht mit datetime('now') im UTC-Format
ends_at: endsAt.toISOString().slice(0, 19).replace('T', ' '),
});
await interaction.reply({ content: '✅ Giveaway gestartet!', flags: MessageFlags.Ephemeral });
}
+43
View File
@@ -0,0 +1,43 @@
// /wunsch — Feature-Wunsch einreichen: Voting-Post mit 👍, Rangliste auf der Webseite
import { SlashCommandBuilder, MessageFlags, EmbedBuilder } from 'discord.js';
import { votingChannelId, publicUrl } from '../../runtime-settings.js';
import { saveWish } from '../../db.js';
export const data = new SlashCommandBuilder()
.setName('wunsch')
.setDescription('Feature-Wunsch für EcoGame einreichen — die Community stimmt ab')
.addStringOption((o) =>
o.setName('idee').setDescription('Dein Vorschlag').setRequired(true).setMaxLength(500)
);
export async function execute(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 idea = interaction.options.getString('idee');
const channel = await interaction.client.channels.fetch(channelId);
const embed = new EmbedBuilder()
.setColor(0xf5c518)
.setAuthor({
name: interaction.member?.displayName ?? interaction.user.username,
iconURL: interaction.user.displayAvatarURL({ size: 64 }),
})
.setTitle('💡 Feature-Wunsch')
.setDescription(idea)
.setURL(`${publicUrl()}/roadmap`)
.setFooter({ text: 'D4RKST3R // VOTING • 👍 = will ich!' });
const message = await channel.send({ embeds: [embed] });
await message.react('👍');
saveWish(message.id, interaction.user.username, idea);
await interaction.editReply(`✅ Wunsch eingereicht — [zur Abstimmung](${message.url})!`);
}
+63
View File
@@ -0,0 +1,63 @@
// Giveaway-Ziehung: Minuten-Scheduler beendet fällige Verlosungen automatisch
import { EmbedBuilder } from 'discord.js';
import { dueGiveaways, markGiveawayEnded, giveawayEntries } from '../db.js';
const CHECK_INTERVAL_MS = 60 * 1000;
/** Zufällige Gewinner ziehen (Fisher-Yates, gekürzt) */
function drawWinners(entries, count) {
const pool = [...entries];
for (let i = pool.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[pool[i], pool[j]] = [pool[j], pool[i]];
}
return pool.slice(0, count);
}
/** Fällige Giveaways beenden — exportiert für Tests und den Timer */
export async function giveawayTick(client) {
for (const g of dueGiveaways()) {
markGiveawayEnded(g.message_id);
try {
const channel = await client.channels.fetch(g.channel_id);
if (!channel?.isTextBased()) continue;
const entries = giveawayEntries(g.message_id);
const winners = drawWinners(entries, g.winners);
const mentions = winners.map((id) => `<@${id}>`).join(' ');
// Original-Embed abschließen (Button raus)
const original = await channel.messages.fetch(g.message_id).catch(() => null);
if (original) {
await original.edit({
embeds: [
new EmbedBuilder()
.setColor(0x666666)
.setTitle(`🎉 Giveaway beendet: ${g.prize}`)
.setDescription(
winners.length
? `🏆 Gewonnen: ${mentions}\n👥 ${entries.length} Teilnahmen`
: 'Keine Teilnahmen — niemand hat gewonnen. 😢'
)
.setFooter({ text: 'D4RKST3R // GIVEAWAY' }),
],
components: [],
}).catch(() => {});
}
if (winners.length) {
await channel.send({
content: `🎉 **${g.prize}** geht an: ${mentions} — Glückwunsch! 🏆`,
allowedMentions: { users: winners },
});
}
console.log(`[giveaway] '${g.prize}' beendet (${entries.length} Teilnahmen)`);
} catch (error) {
console.error('[giveaway] Ziehung fehlgeschlagen:', error);
}
}
}
export function startGiveaways(client) {
setInterval(() => giveawayTick(client).catch((e) => console.error('[giveaway]', e)), CHECK_INTERVAL_MS);
}