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
+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 });
}