From bc000aaa9f172e5894e8962277f8f7b06de411a8 Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Thu, 23 Jul 2026 13:20:44 +0200 Subject: [PATCH] Feature-Voting, Giveaways und Contribution-Heatmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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 --- README.md | 3 ++ frontend/src/pages/Roadmap.jsx | 59 ++++++++++++++++++++++++++ frontend/src/pages/Settings.jsx | 10 +++++ frontend/src/style.css | 28 +++++++++++++ src/bot/client.js | 37 ++++++++++++++++- src/bot/commands/giveaway.js | 62 +++++++++++++++++++++++++++ src/bot/commands/wunsch.js | 43 +++++++++++++++++++ src/bot/giveaways.js | 63 ++++++++++++++++++++++++++++ src/db.js | 74 +++++++++++++++++++++++++++++++++ src/index.js | 2 + src/runtime-settings.js | 5 +++ src/web/api.js | 9 +++- 12 files changed, 391 insertions(+), 4 deletions(-) create mode 100644 src/bot/commands/giveaway.js create mode 100644 src/bot/commands/wunsch.js create mode 100644 src/bot/giveaways.js diff --git a/README.md b/README.md index 8dcf06a..b9a1bd4 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,9 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St | 👋 **Willkommens-Embed** | Begrüßung neuer Member im Brand-Look (braucht Server-Members-Intent) | | 📋 **Mod-Log** | Gelöschte/bearbeitete Nachrichten in einen privaten Log-Kanal | | 🎮 **Server-Monitor** | FiveM-kompatible Game-Server (/dynamic.json): Live-Status-Embed + Spielerzahl in der Bot-Presence | +| 💡 **Feature-Voting** | `/wunsch` → Voting-Post mit 👍; Top-Wünsche öffentlich auf der Roadmap-Seite | +| 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung nach Ablauf | +| 📈 **Contribution-Heatmap** | GitHub-Style-Jahreskalender aus dem Commit-Archiv auf der Roadmap-Seite | | 🏓 `/ping`, 🗄 `/devlog-backfill` | Lebenszeichen · Kanal-Historie nacharchivieren (Admin) | ### Webinterface (`bot.d4rkst3r.de`) diff --git a/frontend/src/pages/Roadmap.jsx b/frontend/src/pages/Roadmap.jsx index a52cd54..bc0507e 100644 --- a/frontend/src/pages/Roadmap.jsx +++ b/frontend/src/pages/Roadmap.jsx @@ -43,12 +43,53 @@ function Milestone({ m }) { ); } +/** GitHub-Style-Heatmap: 52 Wochen × 7 Tage */ +function Heatmap({ days }) { + const byDay = new Map(days.map((d) => [d.day, d.n])); + const max = Math.max(1, ...days.map((d) => d.n)); + const today = new Date(); + // Start: Montag vor ~52 Wochen + const start = new Date(today.getTime() - 364 * 86400000); + start.setDate(start.getDate() - ((start.getDay() + 6) % 7)); + + const weeks = []; + for (let w = 0; w < 53; w++) { + const col = []; + for (let d = 0; d < 7; d++) { + const date = new Date(start.getTime() + (w * 7 + d) * 86400000); + if (date > today) break; + const key = date.toISOString().slice(0, 10); + const n = byDay.get(key) ?? 0; + const level = n === 0 ? 0 : Math.min(4, Math.ceil((n / max) * 4)); + col.push( + + ); + } + weeks.push({col}); + } + const total = days.reduce((sum, d) => sum + d.n, 0); + return ( +
+

// Aktivität — {total} Commits in 12 Monaten

+
{weeks}
+
+ ); +} + export default function Roadmap() { const [data, setData] = useState(null); + const [wishes, setWishes] = useState([]); + const [heatmap, setHeatmap] = useState(null); const [error, setError] = useState(false); useEffect(() => { apiGet('/api/roadmap').then(setData).catch(() => setError(true)); + apiGet('/api/wishes').then((d) => setWishes(d.wishes)).catch(() => {}); + apiGet('/api/heatmap').then((d) => setHeatmap(d.days)).catch(() => {}); window.scrollTo(0, 0); }, []); @@ -73,6 +114,24 @@ export default function Roadmap() {

Noch keine Meilensteine angelegt.

)} + {heatmap && heatmap.length > 0 && } + + {wishes.length > 0 && ( +
+

// Community-Wünsche

+ {wishes.map((w, i) => ( +
+ 👍 {w.score} + {w.idea} + {w.author} +
+ ))} +

+ Eigene Idee? Im Discord einfach /wunsch benutzen. +

+
+ )} + {GROUPS.map(({ id, tag, empty }) => { const items = (data?.milestones ?? []).filter((m) => groupOf(m) === id); if (items.length === 0 && !empty) return null; diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 037b04d..54893f8 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -355,6 +355,16 @@ export default function Settings({ me }) { {channelOptions} +
+ + +
{/* Moderation & Kontakt */} diff --git a/frontend/src/style.css b/frontend/src/style.css index 660ec3b..221c5f5 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -398,6 +398,34 @@ body::after { .card-footer a { color: var(--muted); border-bottom: 1px solid transparent; } .card-footer a:hover { color: var(--neon); } +/* ── HEATMAP + WÜNSCHE (Roadmap) ───────────────────── */ +.hm-wrap { + display: flex; gap: 3px; + overflow-x: auto; + padding: .5rem 0; +} +.hm-week { display: flex; flex-direction: column; gap: 3px; } +.hm-cell { width: 11px; height: 11px; border-radius: 2px; flex-shrink: 0; } +.hm-0 { background: rgba(255, 255, 255, .05); } +.hm-1 { background: rgba(245, 197, 24, .25); } +.hm-2 { background: rgba(245, 197, 24, .5); } +.hm-3 { background: rgba(245, 197, 24, .75); } +.hm-4 { background: var(--neon); box-shadow: 0 0 4px rgba(245, 197, 24, .4); } + +.wish-row { + display: flex; align-items: baseline; gap: 1rem; + padding: .55rem .9rem; + background: var(--bg2); + border: 1px solid rgba(255, 255, 255, .06); + margin-bottom: .5rem; +} +.wish-score { + font-family: var(--mono); font-size: .78rem; + color: var(--neon); white-space: nowrap; min-width: 3.5rem; +} +.wish-idea { flex: 1; font-weight: 300; } +.wish-author { font-family: var(--mono); font-size: .62rem; color: var(--muted2); white-space: nowrap; } + /* ── GALERIE ───────────────────────────────────────── */ .gallery-grid { display: grid; diff --git a/src/bot/client.js b/src/bot/client.js index 3b0e0e2..754e79a 100644 --- a/src/bot/client.js +++ b/src/bot/client.js @@ -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(); diff --git a/src/bot/commands/giveaway.js b/src/bot/commands/giveaway.js new file mode 100644 index 0000000..c3d5667 --- /dev/null +++ b/src/bot/commands/giveaway.js @@ -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 ` + ) + .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 }); +} diff --git a/src/bot/commands/wunsch.js b/src/bot/commands/wunsch.js new file mode 100644 index 0000000..bd64947 --- /dev/null +++ b/src/bot/commands/wunsch.js @@ -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})!`); +} diff --git a/src/bot/giveaways.js b/src/bot/giveaways.js new file mode 100644 index 0000000..de4da73 --- /dev/null +++ b/src/bot/giveaways.js @@ -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); +} diff --git a/src/db.js b/src/db.js index cb5aa97..266ba2c 100644 --- a/src/db.js +++ b/src/db.js @@ -178,6 +178,80 @@ export const listGallery = (limit, offset) => ({ total: countGalleryStmt.get().n, }); +// Feature-Voting (/wunsch) + Giveaways +db.exec(` + CREATE TABLE IF NOT EXISTS wishes ( + message_id TEXT PRIMARY KEY, + author TEXT, + idea TEXT NOT NULL, + score INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS giveaways ( + message_id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL, + prize TEXT NOT NULL, + winners INTEGER NOT NULL DEFAULT 1, + ends_at TEXT NOT NULL, + ended INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS giveaway_entries ( + giveaway_id TEXT NOT NULL, + user_id TEXT NOT NULL, + PRIMARY KEY (giveaway_id, user_id) + ); +`); + +const insertWish = db.prepare('INSERT INTO wishes (message_id, author, idea) VALUES (?, ?, ?)'); +const wishScoreStmt = db.prepare('UPDATE wishes SET score = MAX(0, score + ?) WHERE message_id = ?'); +const hasWishStmt = db.prepare('SELECT 1 FROM wishes WHERE message_id = ?'); +const topWishesStmt = db.prepare( + 'SELECT idea, author, score, created_at FROM wishes ORDER BY score DESC, created_at DESC LIMIT ?' +); +export const saveWish = (messageId, author, idea) => insertWish.run(messageId, author, idea); +export const isWish = (messageId) => Boolean(hasWishStmt.get(messageId)); +export const bumpWish = (messageId, delta) => wishScoreStmt.run(delta, messageId); +export const topWishes = (limit = 20) => topWishesStmt.all(limit); + +const insertGiveaway = db.prepare(` + INSERT INTO giveaways (message_id, channel_id, prize, winners, ends_at) + VALUES (@message_id, @channel_id, @prize, @winners, @ends_at) +`); +const dueGiveawaysStmt = db.prepare( + `SELECT * FROM giveaways WHERE ended = 0 AND ends_at <= datetime('now')` +); +const endGiveawayStmt = db.prepare('UPDATE giveaways SET ended = 1 WHERE message_id = ?'); +const giveawayStmt = db.prepare('SELECT * FROM giveaways WHERE message_id = ?'); +const toggleEntryInsert = db.prepare( + 'INSERT OR IGNORE INTO giveaway_entries (giveaway_id, user_id) VALUES (?, ?)' +); +const toggleEntryDelete = db.prepare( + 'DELETE FROM giveaway_entries WHERE giveaway_id = ? AND user_id = ?' +); +const entriesStmt = db.prepare('SELECT user_id FROM giveaway_entries WHERE giveaway_id = ?'); +export const saveGiveaway = (g) => insertGiveaway.run(g); +export const getGiveaway = (id) => giveawayStmt.get(id) ?? null; +export const dueGiveaways = () => dueGiveawaysStmt.all(); +export const markGiveawayEnded = (id) => endGiveawayStmt.run(id); +export const giveawayEntries = (id) => entriesStmt.all(id).map((r) => r.user_id); +/** Teilnahme togglen — true = jetzt drin, false = ausgetragen */ +export function toggleGiveawayEntry(giveawayId, userId) { + if (toggleEntryInsert.run(giveawayId, userId).changes > 0) return true; + toggleEntryDelete.run(giveawayId, userId); + return false; +} + +/** Commits pro Tag der letzten 365 Tage (Contribution-Heatmap) */ +export function commitHeatmap() { + return db + .prepare(` + SELECT date(committed_at) AS day, count(*) AS n + FROM commits WHERE committed_at >= datetime('now', '-365 days') + GROUP BY day + `) + .all(); +} + // Modmail: DM-Konversationen ↔ Staff-Threads db.exec(` CREATE TABLE IF NOT EXISTS modmail ( diff --git a/src/index.js b/src/index.js index c9c137b..5d97ac7 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,7 @@ import { scheduleWeeklyRecap } from './bot/weekly-recap.js'; import { startWatchdog } from './bot/watchdog.js'; import { scheduleBackups } from './backup.js'; import { startServerMonitor } from './bot/server-monitor.js'; +import { startGiveaways } from './bot/giveaways.js'; process.on('unhandledRejection', (error) => { console.error('[main] Unhandled Rejection:', error); @@ -17,6 +18,7 @@ try { startWatchdog(client); scheduleBackups(client); startServerMonitor(client); + startGiveaways(client); } catch (error) { console.error('[main] Start fehlgeschlagen:', error); process.exit(1); diff --git a/src/runtime-settings.js b/src/runtime-settings.js index 0af100b..3a9abea 100644 --- a/src/runtime-settings.js +++ b/src/runtime-settings.js @@ -47,6 +47,11 @@ export function starboardThreshold() { return Number.isInteger(n) && n >= 1 ? n : 3; } +/** Kanal für Feature-Wünsche (/wunsch) — leer = Feature aus */ +export function votingChannelId() { + return getSetting('voting_channel_id') || null; +} + /** Modmail-Staff-Kanal (privat!) — leer = Feature aus */ export function modmailChannelId() { return getSetting('modmail_channel_id') || null; diff --git a/src/web/api.js b/src/web/api.js index 241eda3..40b2bd2 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -3,7 +3,7 @@ import { EmbedBuilder } from 'discord.js'; import { listDevlogs, searchDevlogs, listCommits, listReleases, archiveStats, getSetting, setSetting, createApiKey, listApiKeys, deleteApiKey, - listGallery, listPlaytesters, + listGallery, listPlaytesters, topWishes, commitHeatmap, } from '../db.js'; import { config } from '../config.js'; import { removeDevlog } from '../bot/devlog-archive.js'; @@ -71,6 +71,10 @@ export function registerApiRoutes(app, client) { return { playtesters: listPlaytesters() }; }); + // Community-Wünsche (Top 20) + Commit-Heatmap — öffentlich + app.get('/api/wishes', async () => ({ wishes: topWishes(20) })); + app.get('/api/heatmap', async () => ({ days: commitHeatmap() })); + // Roadmap — öffentlich, aus Gitea-Milestones (5-Minuten-Cache gegen API-Hammering) let roadmapCache = { at: 0, repo: null, data: null }; app.get('/api/roadmap', async (request, reply) => { @@ -207,6 +211,7 @@ ${rssItems} welcome_channel_id: getSetting('welcome_channel_id') ?? '', modlog_channel_id: getSetting('modlog_channel_id') ?? '', status_channel_id: getSetting('status_channel_id') ?? '', + voting_channel_id: getSetting('voting_channel_id') ?? '', gameservers: getSetting('gameservers') ?? '', }; } @@ -239,7 +244,7 @@ ${rssItems} const OPTIONAL_CHANNELS = [ 'release_channel_id', 'backup_channel_id', 'starboard_channel_id', 'screenshot_channel_id', 'modmail_channel_id', 'welcome_channel_id', - 'modlog_channel_id', 'status_channel_id', + 'modlog_channel_id', 'status_channel_id', 'voting_channel_id', ]; for (const key of ['commit_channel_id', 'devlog_channel_id', ...OPTIONAL_CHANNELS]) { if (body[key] === undefined) continue;