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:
@@ -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) |
|
| 👋 **Willkommens-Embed** | Begrüßung neuer Member im Brand-Look (braucht Server-Members-Intent) |
|
||||||
| 📋 **Mod-Log** | Gelöschte/bearbeitete Nachrichten in einen privaten Log-Kanal |
|
| 📋 **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 |
|
| 🎮 **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) |
|
| 🏓 `/ping`, 🗄 `/devlog-backfill` | Lebenszeichen · Kanal-Historie nacharchivieren (Admin) |
|
||||||
|
|
||||||
### Webinterface (`bot.d4rkst3r.de`)
|
### Webinterface (`bot.d4rkst3r.de`)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<span
|
||||||
|
key={d}
|
||||||
|
className={`hm-cell hm-${level}`}
|
||||||
|
title={`${key}: ${n} Commit${n === 1 ? '' : 's'}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
weeks.push(<span className="hm-week" key={w}>{col}</span>);
|
||||||
|
}
|
||||||
|
const total = days.reduce((sum, d) => sum + d.n, 0);
|
||||||
|
return (
|
||||||
|
<div className="roadmap-group">
|
||||||
|
<h2 className="settings-title">// Aktivität — {total} Commits in 12 Monaten</h2>
|
||||||
|
<div className="hm-wrap">{weeks}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Roadmap() {
|
export default function Roadmap() {
|
||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState(null);
|
||||||
|
const [wishes, setWishes] = useState([]);
|
||||||
|
const [heatmap, setHeatmap] = useState(null);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiGet('/api/roadmap').then(setData).catch(() => setError(true));
|
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);
|
window.scrollTo(0, 0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -73,6 +114,24 @@ export default function Roadmap() {
|
|||||||
<p className="notice">Noch keine Meilensteine angelegt.</p>
|
<p className="notice">Noch keine Meilensteine angelegt.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{heatmap && heatmap.length > 0 && <Heatmap days={heatmap} />}
|
||||||
|
|
||||||
|
{wishes.length > 0 && (
|
||||||
|
<div className="roadmap-group">
|
||||||
|
<h2 className="settings-title">// Community-Wünsche</h2>
|
||||||
|
{wishes.map((w, i) => (
|
||||||
|
<div className="wish-row" key={`${w.created_at}-${i}`}>
|
||||||
|
<span className="wish-score">👍 {w.score}</span>
|
||||||
|
<span className="wish-idea">{w.idea}</span>
|
||||||
|
<span className="wish-author">{w.author}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="notice" style={{ padding: '.6rem 0 0' }}>
|
||||||
|
Eigene Idee? Im Discord einfach <code>/wunsch</code> benutzen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{GROUPS.map(({ id, tag, empty }) => {
|
{GROUPS.map(({ id, tag, empty }) => {
|
||||||
const items = (data?.milestones ?? []).filter((m) => groupOf(m) === id);
|
const items = (data?.milestones ?? []).filter((m) => groupOf(m) === id);
|
||||||
if (items.length === 0 && !empty) return null;
|
if (items.length === 0 && !empty) return null;
|
||||||
|
|||||||
@@ -355,6 +355,16 @@ export default function Settings({ me }) {
|
|||||||
{channelOptions}
|
{channelOptions}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Voting-Kanal <span className="field-hint">/wunsch postet hier; Rangliste auf der Roadmap-Seite</span></label>
|
||||||
|
<select
|
||||||
|
value={form.voting_channel_id ?? ''}
|
||||||
|
onChange={(e) => setForm({ ...form, voting_channel_id: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">— deaktiviert —</option>
|
||||||
|
{channelOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Moderation & Kontakt */}
|
{/* Moderation & Kontakt */}
|
||||||
|
|||||||
@@ -398,6 +398,34 @@ body::after {
|
|||||||
.card-footer a { color: var(--muted); border-bottom: 1px solid transparent; }
|
.card-footer a { color: var(--muted); border-bottom: 1px solid transparent; }
|
||||||
.card-footer a:hover { color: var(--neon); }
|
.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 ───────────────────────────────────────── */
|
/* ── GALERIE ───────────────────────────────────────── */
|
||||||
.gallery-grid {
|
.gallery-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
+35
-2
@@ -5,15 +5,17 @@ import { devlogChannelId, devlogPingRoleId, playtesterRoleId } from '../runtime-
|
|||||||
import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js';
|
import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js';
|
||||||
import { registerCommunityListeners } from './community.js';
|
import { registerCommunityListeners } from './community.js';
|
||||||
import { registerModTools } from './mod-tools.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 ping from './commands/ping.js';
|
||||||
import * as devlogBackfill from './commands/devlog-backfill.js';
|
import * as devlogBackfill from './commands/devlog-backfill.js';
|
||||||
import * as bug from './commands/bug.js';
|
import * as bug from './commands/bug.js';
|
||||||
import * as playtesterSetup from './commands/playtester-setup.js';
|
import * as playtesterSetup from './commands/playtester-setup.js';
|
||||||
import * as galerieBackfill from './commands/galerie-backfill.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 }
|
// 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() {
|
export async function startBot() {
|
||||||
const client = new Client({
|
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)
|
// In Discord gelöscht = im Archiv gelöscht (inkl. lokaler Bilder)
|
||||||
client.on(Events.MessageDelete, async (message) => {
|
client.on(Events.MessageDelete, async (message) => {
|
||||||
if (message.channelId !== devlogChannelId()) return;
|
if (message.channelId !== devlogChannelId()) return;
|
||||||
@@ -102,6 +117,24 @@ export async function startBot() {
|
|||||||
return;
|
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
|
// 🧪-Button: Playtester-Rolle + Liste togglen
|
||||||
if (interaction.isButton() && interaction.customId === 'playtester_toggle') {
|
if (interaction.isButton() && interaction.customId === 'playtester_toggle') {
|
||||||
const roleId = playtesterRoleId();
|
const roleId = playtesterRoleId();
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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})!`);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -178,6 +178,80 @@ export const listGallery = (limit, offset) => ({
|
|||||||
total: countGalleryStmt.get().n,
|
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
|
// Modmail: DM-Konversationen ↔ Staff-Threads
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS modmail (
|
CREATE TABLE IF NOT EXISTS modmail (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { scheduleWeeklyRecap } from './bot/weekly-recap.js';
|
|||||||
import { startWatchdog } from './bot/watchdog.js';
|
import { startWatchdog } from './bot/watchdog.js';
|
||||||
import { scheduleBackups } from './backup.js';
|
import { scheduleBackups } from './backup.js';
|
||||||
import { startServerMonitor } from './bot/server-monitor.js';
|
import { startServerMonitor } from './bot/server-monitor.js';
|
||||||
|
import { startGiveaways } from './bot/giveaways.js';
|
||||||
|
|
||||||
process.on('unhandledRejection', (error) => {
|
process.on('unhandledRejection', (error) => {
|
||||||
console.error('[main] Unhandled Rejection:', error);
|
console.error('[main] Unhandled Rejection:', error);
|
||||||
@@ -17,6 +18,7 @@ try {
|
|||||||
startWatchdog(client);
|
startWatchdog(client);
|
||||||
scheduleBackups(client);
|
scheduleBackups(client);
|
||||||
startServerMonitor(client);
|
startServerMonitor(client);
|
||||||
|
startGiveaways(client);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[main] Start fehlgeschlagen:', error);
|
console.error('[main] Start fehlgeschlagen:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ export function starboardThreshold() {
|
|||||||
return Number.isInteger(n) && n >= 1 ? n : 3;
|
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 */
|
/** Modmail-Staff-Kanal (privat!) — leer = Feature aus */
|
||||||
export function modmailChannelId() {
|
export function modmailChannelId() {
|
||||||
return getSetting('modmail_channel_id') || null;
|
return getSetting('modmail_channel_id') || null;
|
||||||
|
|||||||
+7
-2
@@ -3,7 +3,7 @@ import { EmbedBuilder } from 'discord.js';
|
|||||||
import {
|
import {
|
||||||
listDevlogs, searchDevlogs, listCommits, listReleases, archiveStats,
|
listDevlogs, searchDevlogs, listCommits, listReleases, archiveStats,
|
||||||
getSetting, setSetting, createApiKey, listApiKeys, deleteApiKey,
|
getSetting, setSetting, createApiKey, listApiKeys, deleteApiKey,
|
||||||
listGallery, listPlaytesters,
|
listGallery, listPlaytesters, topWishes, commitHeatmap,
|
||||||
} from '../db.js';
|
} from '../db.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { removeDevlog } from '../bot/devlog-archive.js';
|
import { removeDevlog } from '../bot/devlog-archive.js';
|
||||||
@@ -71,6 +71,10 @@ export function registerApiRoutes(app, client) {
|
|||||||
return { playtesters: listPlaytesters() };
|
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)
|
// Roadmap — öffentlich, aus Gitea-Milestones (5-Minuten-Cache gegen API-Hammering)
|
||||||
let roadmapCache = { at: 0, repo: null, data: null };
|
let roadmapCache = { at: 0, repo: null, data: null };
|
||||||
app.get('/api/roadmap', async (request, reply) => {
|
app.get('/api/roadmap', async (request, reply) => {
|
||||||
@@ -207,6 +211,7 @@ ${rssItems}
|
|||||||
welcome_channel_id: getSetting('welcome_channel_id') ?? '',
|
welcome_channel_id: getSetting('welcome_channel_id') ?? '',
|
||||||
modlog_channel_id: getSetting('modlog_channel_id') ?? '',
|
modlog_channel_id: getSetting('modlog_channel_id') ?? '',
|
||||||
status_channel_id: getSetting('status_channel_id') ?? '',
|
status_channel_id: getSetting('status_channel_id') ?? '',
|
||||||
|
voting_channel_id: getSetting('voting_channel_id') ?? '',
|
||||||
gameservers: getSetting('gameservers') ?? '',
|
gameservers: getSetting('gameservers') ?? '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -239,7 +244,7 @@ ${rssItems}
|
|||||||
const OPTIONAL_CHANNELS = [
|
const OPTIONAL_CHANNELS = [
|
||||||
'release_channel_id', 'backup_channel_id', 'starboard_channel_id',
|
'release_channel_id', 'backup_channel_id', 'starboard_channel_id',
|
||||||
'screenshot_channel_id', 'modmail_channel_id', 'welcome_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]) {
|
for (const key of ['commit_channel_id', 'devlog_channel_id', ...OPTIONAL_CHANNELS]) {
|
||||||
if (body[key] === undefined) continue;
|
if (body[key] === undefined) continue;
|
||||||
|
|||||||
Reference in New Issue
Block a user