Community-Endgame: Alpha-Keys, Bewerbungen, Events, Stats, Triggers, /remind, Social, Temp-Voice + MEE6-Bot-Karte

- Alpha-Keys: Pool im Community-Tab, Ein-Klick-Verteilung per DM an alle Playtester
  (Key bleibt frei, wenn die DM geblockt wird)
- Bewerbungs-Formulare: Builder im neuen Bewerbungen-Tab (bis 5 Fragen),
  Button → Discord-Modal → Review-Embed mit /, Rolle + DM bei Entscheidung
- Events: GuildScheduledEventCreate → Announce-Embed; öffentliche /events-Seite
  aus den Discord-Events (5-min-Cache)
- Server-Stats: activity_daily (Nachrichten/Joins/Leaves) → Balken-Chart auf /level
- Triggers (Auto-Antworten, 30s-Cooldown), /remind (DM-Scheduler),
  Twitch-Live (Helix, App-Creds write-only) + YouTube-RSS-Announcements,
  Temp-Voice (Join to Create, Cleanup bei Leerstand + Start)
- Brand-Tab: MEE6-Style Bot-Identity-Karte (Avatar-Vorschau mit Status-Dot,
  Bot-Name via setUsername, Presence online/idle/dnd, Aktivität)
- Neue Intents: GuildVoiceStates, GuildScheduledEvents; alles smoke-getestet

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 01:02:47 +02:00
co-authored by Claude Fable 5
parent f7b261c648
commit e72e6c8991
16 changed files with 1368 additions and 47 deletions
+184
View File
@@ -0,0 +1,184 @@
// Bewerbungs-Formulare: Button-Post → Discord-Modal (bis 5 Fragen) → Review-Embed
// mit ✅/❌ im Staff-Kanal; Annahme vergibt optional eine Rolle + DM.
import {
ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, MessageFlags,
ModalBuilder, TextInputBuilder, TextInputStyle,
} from 'discord.js';
import {
getAppForm, setAppFormMessage,
createApplication, getApplication, openApplicationOf, setApplicationStatus,
} from '../db.js';
import { brandColor, brandColor2, brandFooter } from '../runtime-settings.js';
export const MAX_QUESTIONS = 5; // Discord-Modal-Limit
/** Bewerbungs-Post (Embed + Button) senden/aktualisieren */
export async function publishAppForm(client, formId) {
const form = getAppForm(formId);
if (!form) throw new Error('Formular nicht gefunden');
if (!form.post_channel_id) throw new Error('Kein Post-Kanal gewählt');
if (!form.review_channel_id) throw new Error('Kein Review-Kanal gewählt');
if (JSON.parse(form.questions || '[]').length === 0) throw new Error('Formular hat keine Fragen');
const channel = await client.channels.fetch(form.post_channel_id);
if (!channel?.isTextBased()) throw new Error('Post-Kanal nicht gefunden');
const payload = {
embeds: [
new EmbedBuilder()
.setColor(brandColor())
.setTitle(`📋 ${form.title}`)
.setDescription(form.description?.trim() || 'Klick auf den Button und füll das Formular aus.')
.setFooter({ text: brandFooter('BEWERBUNG') }),
],
components: [
new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId(`apply:${form.id}`)
.setStyle(ButtonStyle.Primary)
.setLabel('Jetzt bewerben')
.setEmoji('📋')
),
],
};
if (form.message_id) {
const existing = await channel.messages.fetch(form.message_id).catch(() => null);
if (existing) {
await existing.edit(payload);
return form.message_id;
}
}
const message = await channel.send(payload);
setAppFormMessage(form.id, message.id);
return message.id;
}
export async function unpublishAppForm(client, form) {
if (!form.post_channel_id || !form.message_id) return;
const channel = await client.channels.fetch(form.post_channel_id).catch(() => null);
const message = await channel?.messages?.fetch(form.message_id).catch(() => null);
await message?.delete().catch(() => {});
}
/** Button „Jetzt bewerben" → Modal öffnen */
export async function handleApplyButton(interaction) {
const formId = Number(interaction.customId.split(':')[1]);
const form = getAppForm(formId);
if (!form) {
await interaction.reply({ content: '❌ Dieses Formular ist nicht mehr aktiv.', flags: MessageFlags.Ephemeral });
return;
}
if (openApplicationOf(formId, interaction.user.id)) {
await interaction.reply({
content: '❕ Du hast hier schon eine offene Bewerbung — das Team meldet sich!',
flags: MessageFlags.Ephemeral,
});
return;
}
const modal = new ModalBuilder()
.setCustomId(`applymodal:${form.id}`)
.setTitle(form.title.slice(0, 45));
JSON.parse(form.questions).slice(0, MAX_QUESTIONS).forEach((q, i) => {
modal.addComponents(
new ActionRowBuilder().addComponents(
new TextInputBuilder()
.setCustomId(`q${i}`)
.setLabel(String(q).slice(0, 45))
.setStyle(TextInputStyle.Paragraph)
.setMaxLength(600)
.setRequired(true)
)
);
});
await interaction.showModal(modal);
}
/** Modal abgeschickt → speichern + Review-Embed in den Staff-Kanal */
export async function handleApplyModal(interaction) {
const formId = Number(interaction.customId.split(':')[1]);
const form = getAppForm(formId);
if (!form) {
await interaction.reply({ content: '❌ Formular nicht mehr aktiv.', flags: MessageFlags.Ephemeral });
return;
}
const questions = JSON.parse(form.questions);
const answers = questions.map((q, i) => ({
q,
a: interaction.fields.getTextInputValue(`q${i}`),
}));
const appId = createApplication(
form.id, interaction.user.id,
interaction.member?.displayName ?? interaction.user.username,
answers
);
const review = await interaction.client.channels.fetch(form.review_channel_id).catch(() => null);
if (review?.isTextBased()) {
await review.send({
embeds: [
new EmbedBuilder()
.setColor(brandColor())
.setAuthor({
name: `${interaction.user.username} (${interaction.user.id})`,
iconURL: interaction.user.displayAvatarURL({ size: 64 }),
})
.setTitle(`📋 Bewerbung #${appId}${form.title}`)
.addFields(answers.map(({ q, a }) => ({
name: String(q).slice(0, 250),
value: String(a).slice(0, 1000) || '—',
})))
.setFooter({ text: brandFooter('BEWERBUNG') })
.setTimestamp(),
],
components: [
new ActionRowBuilder().addComponents(
new ButtonBuilder().setCustomId(`appdec:${appId}:approve`).setStyle(ButtonStyle.Success).setLabel('Annehmen').setEmoji('✅'),
new ButtonBuilder().setCustomId(`appdec:${appId}:deny`).setStyle(ButtonStyle.Danger).setLabel('Ablehnen').setEmoji('❌')
),
],
});
}
await interaction.reply({
content: '✅ Bewerbung eingereicht — danke! Du bekommst eine DM, sobald sie geprüft wurde.',
flags: MessageFlags.Ephemeral,
});
}
/** ✅/❌ im Review-Kanal → Status, Rolle, DM, Embed abschließen */
export async function handleReviewButton(interaction) {
const [, appIdRaw, decision] = interaction.customId.split(':');
const application = getApplication(Number(appIdRaw));
if (!application || application.status !== 'open') {
await interaction.reply({ content: '❌ Bewerbung nicht (mehr) offen.', flags: MessageFlags.Ephemeral });
return;
}
const form = getAppForm(application.form_id);
const approved = decision === 'approve';
setApplicationStatus(application.id, approved ? 'approved' : 'denied');
// Rolle bei Annahme
if (approved && form?.approve_role_id) {
const member = await interaction.guild.members.fetch(application.user_id).catch(() => null);
await member?.roles?.add(form.approve_role_id).catch((e) =>
console.error(`[bewerbung] Rolle fehlgeschlagen: ${e.message}`)
);
}
// DM an den Bewerber
const user = await interaction.client.users.fetch(application.user_id).catch(() => null);
await user?.send(
approved
? `✅ Deine Bewerbung **„${form?.title ?? ''}"** wurde angenommen — willkommen! 🎉`
: `❌ Deine Bewerbung **„${form?.title ?? ''}"** wurde leider abgelehnt.`
).catch(() => {});
// Review-Embed abschließen
const original = EmbedBuilder.from(interaction.message.embeds[0])
.setColor(approved ? brandColor() : brandColor2())
.setTitle(`${approved ? '✅ Angenommen' : '❌ Abgelehnt'}${interaction.message.embeds[0].title?.replace('📋 ', '') ?? ''}`)
.setFooter({ text: `${brandFooter('BEWERBUNG')}${approved ? 'angenommen' : 'abgelehnt'} von ${interaction.member?.displayName ?? interaction.user.username}` });
await interaction.update({ embeds: [original], components: [] });
}
+30 -1
View File
@@ -7,6 +7,8 @@ import { registerCommunityListeners } from './community.js';
import { registerModTools } from './mod-tools.js';
import { registerLevels } from './levels.js';
import { registerPresence } from './presence.js';
import { registerExtras } from './extras.js';
import { handleApplyButton, handleApplyModal, handleReviewButton } from './app-forms.js';
import { handleRoleMenuButton } from './role-menus.js';
import {
addPlaytester, removePlaytester, isWish, bumpWish,
@@ -28,11 +30,12 @@ import * as timeout from './commands/timeout.js';
import * as purge from './commands/purge.js';
import * as rank from './commands/rank.js';
import * as tag from './commands/tag.js';
import * as remind from './commands/remind.js';
// Alle Commands hier eintragen — jedes Modul exportiert { data, execute }
const commandModules = [
ping, devlogBackfill, bug, playtesterSetup, galerieBackfill,
wunsch, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag,
wunsch, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag, remind,
];
export async function startBot() {
@@ -49,6 +52,9 @@ export async function startBot() {
// privilegierten "Server Members Intent" im Developer Portal!
GatewayIntentBits.DirectMessages,
GatewayIntentBits.GuildMembers,
// Temp-Voice (Join to Create) + Event-Ankündigungen
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.GuildScheduledEvents,
],
// Partials: Delete/Reaction-Events für ungecachte Nachrichten + DM-Kanäle
partials: [Partials.Message, Partials.Reaction, Partials.Channel],
@@ -144,6 +150,28 @@ export async function startBot() {
return;
}
// Bewerbungen: Button → Modal → Review-Entscheidung
try {
if (interaction.isButton() && interaction.customId.startsWith('apply:')) {
await handleApplyButton(interaction);
return;
}
if (interaction.isModalSubmit() && interaction.customId.startsWith('applymodal:')) {
await handleApplyModal(interaction);
return;
}
if (interaction.isButton() && interaction.customId.startsWith('appdec:')) {
await handleReviewButton(interaction);
return;
}
} catch (error) {
console.error('[bewerbung] Fehler:', error);
if (!interaction.replied && !interaction.deferred) {
await interaction.reply({ content: '❌ Da ist etwas schiefgelaufen.', flags: MessageFlags.Ephemeral }).catch(() => {});
}
return;
}
// Rollen-Menü-Buttons: rolemenu:<menuId>:<roleId>
if (interaction.isButton() && interaction.customId.startsWith('rolemenu:')) {
try {
@@ -298,6 +326,7 @@ export async function startBot() {
registerModTools(client);
registerLevels(client);
registerPresence(client);
registerExtras(client);
await client.login(config.discordToken);
return client;
+26
View File
@@ -0,0 +1,26 @@
// /remind — Erinnerung per DM (z. B. /remind dauer:2h text:Server neustarten)
import { SlashCommandBuilder, MessageFlags } from 'discord.js';
import { parseDuration } from './giveaway.js';
import { createReminder } from '../../db.js';
export const data = new SlashCommandBuilder()
.setName('remind')
.setDescription('Erinnert dich per DM')
.addStringOption((o) => o.setName('dauer').setDescription('z. B. 30m, 2h, 1d').setRequired(true))
.addStringOption((o) => o.setName('text').setDescription('Woran erinnern?').setRequired(true).setMaxLength(500));
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`.', flags: MessageFlags.Ephemeral });
return;
}
const text = interaction.options.getString('text');
const remindAt = new Date(Date.now() + duration).toISOString().slice(0, 19).replace('T', ' ');
createReminder(interaction.user.id, text, remindAt);
await interaction.reply({
content: `⏰ Alles klar — ich melde mich <t:${Math.floor((Date.now() + duration) / 1000)}:R> per DM.`,
flags: MessageFlags.Ephemeral,
});
}
+126
View File
@@ -0,0 +1,126 @@
// Kleinkram-Paket: Triggers (Auto-Antworten), Reminder-Scheduler, Temp-Voice
// (Join to Create), Event-Ankündigungen und Aktivitäts-Zählung.
import { ChannelType, EmbedBuilder, Events } from 'discord.js';
import {
listTriggers, dueReminders, markReminderSent, countActivity,
trackTempVoice, untrackTempVoice, isTempVoice, listTempVoice,
} from '../db.js';
import {
tempVoiceChannelId, eventsAnnounceChannelId, brandColor, brandFooter,
} from '../runtime-settings.js';
/* ── Triggers ──────────────────────────────────────── */
const triggerCooldown = new Map(); // `${channelId}:${keyword}` → ts
const TRIGGER_COOLDOWN_MS = 30_000;
async function handleTriggers(message) {
const content = message.content?.toLowerCase();
if (!content) return;
for (const t of listTriggers()) {
if (!content.includes(t.keyword.toLowerCase())) continue;
const key = `${message.channelId}:${t.keyword}`;
if (Date.now() - (triggerCooldown.get(key) ?? 0) < TRIGGER_COOLDOWN_MS) continue;
triggerCooldown.set(key, Date.now());
await message.reply({ content: t.reply.slice(0, 2000), allowedMentions: { parse: [] } }).catch(() => {});
break; // max. ein Trigger pro Nachricht
}
}
/* ── Temp-Voice (Join to Create) ───────────────────── */
async function handleVoiceState(oldState, newState) {
const hubId = tempVoiceChannelId();
// Join in den Hub → eigenen Kanal erstellen und rüberschieben
if (hubId && newState.channelId === hubId && newState.member) {
const hub = newState.channel;
const channel = await newState.guild.channels.create({
name: `🔊 ${newState.member.displayName}`.slice(0, 90),
type: ChannelType.GuildVoice,
parent: hub.parentId ?? undefined,
});
trackTempVoice(channel.id);
await newState.member.voice.setChannel(channel).catch(() => {});
}
// Temp-Kanal leer → löschen
if (oldState.channelId && isTempVoice(oldState.channelId)) {
const channel = oldState.channel ?? (await oldState.guild.channels.fetch(oldState.channelId).catch(() => null));
if (channel && channel.members.size === 0) {
untrackTempVoice(channel.id);
await channel.delete().catch(() => {});
}
}
}
/** Beim Start: verwaiste leere Temp-Kanäle aufräumen */
async function cleanupTempVoice(client) {
for (const id of listTempVoice()) {
const channel = await client.channels.fetch(id).catch(() => null);
if (!channel) {
untrackTempVoice(id);
} else if (channel.members.size === 0) {
untrackTempVoice(id);
await channel.delete().catch(() => {});
}
}
}
/* ── Reminder ──────────────────────────────────────── */
export async function remindersTick(client) {
for (const r of dueReminders()) {
markReminderSent(r.id);
const user = await client.users.fetch(r.user_id).catch(() => null);
await user?.send(`⏰ **Erinnerung:** ${r.text}`).catch(() => {});
}
}
/* ── Event-Ankündigung ─────────────────────────────── */
async function announceEvent(event) {
const channelId = eventsAnnounceChannelId();
if (!channelId) return;
const channel = await event.client.channels.fetch(channelId).catch(() => null);
if (!channel?.isTextBased()) return;
const start = event.scheduledStartTimestamp
? `<t:${Math.floor(event.scheduledStartTimestamp / 1000)}:F> (<t:${Math.floor(event.scheduledStartTimestamp / 1000)}:R>)`
: '—';
const embed = new EmbedBuilder()
.setColor(brandColor())
.setTitle(`📅 Neues Event: ${event.name}`)
.setDescription(
`${event.description?.slice(0, 1500) ?? ''}\n\n🕒 ${start}\n[→ Zum Event](${event.url})`
)
.setFooter({ text: brandFooter('EVENT') });
if (event.coverImageURL?.()) embed.setImage(event.coverImageURL({ size: 1024 }));
await channel.send({ embeds: [embed] }).catch(() => {});
}
/* ── Registrierung ─────────────────────────────────── */
export function registerExtras(client) {
client.on(Events.MessageCreate, async (message) => {
if (!message.guildId || message.author?.bot) return;
try {
countActivity('message');
await handleTriggers(message);
} catch (error) {
console.error('[extras] Message-Handling:', error);
}
});
client.on(Events.GuildMemberAdd, () => { try { countActivity('join'); } catch {} });
client.on(Events.GuildMemberRemove, () => { try { countActivity('leave'); } catch {} });
client.on(Events.VoiceStateUpdate, (oldState, newState) =>
handleVoiceState(oldState, newState).catch((e) => console.error('[tempvoice]', e))
);
client.on(Events.GuildScheduledEventCreate, (event) =>
announceEvent(event).catch((e) => console.error('[events]', e))
);
client.once(Events.ClientReady, () => cleanupTempVoice(client).catch(() => {}));
setInterval(() => remindersTick(client).catch((e) => console.error('[remind]', e)), 60_000);
}
+9 -8
View File
@@ -1,7 +1,7 @@
// Bot-Status (Presence) aus den Branding-Settings — hat Vorrang vor dem
// Server-Monitor (der die Presence nur nutzt, wenn hier nichts gesetzt ist).
// Server-Monitor (der die Presence nur nutzt, wenn hier kein Text gesetzt ist).
import { ActivityType, Events } from 'discord.js';
import { botStatusText, botStatusType } from '../runtime-settings.js';
import { botStatusText, botStatusType, botPresenceStatus } from '../runtime-settings.js';
const TYPES = {
playing: ActivityType.Playing,
@@ -10,15 +10,16 @@ const TYPES = {
custom: ActivityType.Custom,
};
/** Presence aus den Settings anwenden (leerer Text = Presence löschen) */
/** Presence aus den Settings anwenden (Status immer, Aktivität nur wenn Text gesetzt) */
export function applyBotStatus(client) {
const text = botStatusText();
try {
if (!text) {
client.user?.setPresence?.({ activities: [] });
return;
}
client.user?.setActivity?.(text, { type: TYPES[botStatusType()] ?? ActivityType.Custom });
client.user?.setPresence?.({
status: botPresenceStatus(),
activities: text
? [{ name: text, type: TYPES[botStatusType()] ?? ActivityType.Custom }]
: [],
});
} catch (error) {
console.error('[presence] Status setzen fehlgeschlagen:', error.message);
}
+103
View File
@@ -0,0 +1,103 @@
// Twitch-/YouTube-Benachrichtigungen: pollt alle 5 min und postet in den
// Social-Kanal. YouTube läuft ohne Key (RSS), Twitch braucht App-Credentials
// (dev.twitch.tv → App registrieren → Client-ID + Secret im System-Tab).
import { EmbedBuilder } from 'discord.js';
import { getSetting, setSetting } from '../db.js';
import {
socialAnnounceChannelId, youtubeChannelId,
twitchChannel, twitchClientId, twitchClientSecret,
brandColor, brandColor2, brandFooter,
} from '../runtime-settings.js';
const CHECK_INTERVAL_MS = 5 * 60 * 1000;
let twitchToken = null; // { token, expiresAt }
async function announce(client, embed) {
const channelId = socialAnnounceChannelId();
if (!channelId) return;
const channel = await client.channels.fetch(channelId).catch(() => null);
if (channel?.isTextBased()) await channel.send({ embeds: [embed] }).catch(() => {});
}
/* ── YouTube (RSS, ohne API-Key) ───────────────────── */
async function checkYouTube(client) {
const channelId = youtubeChannelId();
if (!channelId) return;
const res = await fetch(`https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`, {
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) return;
const xml = await res.text();
const videoId = xml.match(/<yt:videoId>([^<]+)<\/yt:videoId>/)?.[1];
const title = xml.match(/<entry>[\s\S]*?<title>([^<]+)<\/title>/)?.[1];
if (!videoId) return;
const last = getSetting('yt_last_video');
if (last === videoId) return;
setSetting('yt_last_video', videoId);
if (last === null) return; // Erststart: nur merken, nicht alte Videos posten
await announce(client, new EmbedBuilder()
.setColor(brandColor2())
.setTitle(`▶️ Neues Video: ${title ?? 'YouTube'}`)
.setURL(`https://www.youtube.com/watch?v=${videoId}`)
.setImage(`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`)
.setFooter({ text: brandFooter('YOUTUBE') }));
console.log(`[social] Neues YouTube-Video angekündigt (${videoId})`);
}
/* ── Twitch (Helix, Client-Credentials) ────────────── */
async function getTwitchToken() {
if (twitchToken && Date.now() < twitchToken.expiresAt) return twitchToken.token;
const res = await fetch('https://id.twitch.tv/oauth2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: twitchClientId(),
client_secret: twitchClientSecret(),
grant_type: 'client_credentials',
}),
});
if (!res.ok) throw new Error(`Twitch-Token ${res.status}`);
const data = await res.json();
twitchToken = { token: data.access_token, expiresAt: Date.now() + (data.expires_in - 60) * 1000 };
return twitchToken.token;
}
async function checkTwitch(client) {
const login = twitchChannel();
if (!login || !twitchClientId() || !twitchClientSecret()) return;
const token = await getTwitchToken();
const res = await fetch(`https://api.twitch.tv/helix/streams?user_login=${encodeURIComponent(login)}`, {
headers: { 'Client-ID': twitchClientId(), Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) return;
const stream = (await res.json()).data?.[0] ?? null;
const wasLive = getSetting('twitch_live') === '1';
if (stream && !wasLive) {
setSetting('twitch_live', '1');
await announce(client, new EmbedBuilder()
.setColor(0x9146ff)
.setTitle(`🔴 ${login} ist LIVE: ${stream.title ?? ''}`.slice(0, 250))
.setURL(`https://twitch.tv/${login}`)
.setDescription(stream.game_name ? `Spielt **${stream.game_name}**` : null)
.setImage(stream.thumbnail_url?.replace('{width}', '1280').replace('{height}', '720') ?? null)
.setFooter({ text: brandFooter('TWITCH') }));
console.log('[social] Twitch-Live angekündigt');
} else if (!stream && wasLive) {
setSetting('twitch_live', '0');
}
}
export function startSocialNotify(client) {
const tick = async () => {
try { await checkYouTube(client); } catch (e) { console.error('[social] YouTube:', e.message); }
try { await checkTwitch(client); } catch (e) { console.error('[social] Twitch:', e.message); }
};
setInterval(tick, CHECK_INTERVAL_MS);
}
+152
View File
@@ -252,6 +252,158 @@ export function commitHeatmap() {
.all();
}
// Alpha-Keys: Pool + Zuweisung an Playtester
db.exec(`
CREATE TABLE IF NOT EXISTS alpha_keys (
key TEXT PRIMARY KEY,
assigned_to TEXT,
assigned_at TEXT
);
`);
const insertAlphaKey = db.prepare('INSERT OR IGNORE INTO alpha_keys (key) VALUES (?)');
const freeKeyCountStmt = db.prepare('SELECT count(*) AS n FROM alpha_keys WHERE assigned_to IS NULL');
const assignedKeysStmt = db.prepare(
`SELECT key, assigned_to, assigned_at FROM alpha_keys WHERE assigned_to IS NOT NULL ORDER BY assigned_at`
);
const hasKeyForUserStmt = db.prepare('SELECT key FROM alpha_keys WHERE assigned_to = ?');
const reserveKeyStmt = db.prepare(`
UPDATE alpha_keys SET assigned_to = ?, assigned_at = datetime('now')
WHERE key = (SELECT key FROM alpha_keys WHERE assigned_to IS NULL LIMIT 1)
RETURNING key
`);
const unreserveKeyStmt = db.prepare('UPDATE alpha_keys SET assigned_to = NULL, assigned_at = NULL WHERE key = ?');
export const addAlphaKeys = db.transaction((keys) => {
let added = 0;
for (const k of keys) added += insertAlphaKey.run(k).changes;
return added;
});
export const freeAlphaKeyCount = () => freeKeyCountStmt.get().n;
export const assignedAlphaKeys = () => assignedKeysStmt.all();
export const alphaKeyOf = (userId) => hasKeyForUserStmt.get(userId)?.key ?? null;
export const reserveAlphaKey = (userId) => reserveKeyStmt.get(userId)?.key ?? null;
export const unreserveAlphaKey = (key) => unreserveKeyStmt.run(key);
// Bewerbungs-Formulare (Modals) + eingereichte Bewerbungen
db.exec(`
CREATE TABLE IF NOT EXISTS app_forms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
review_channel_id TEXT,
approve_role_id TEXT,
post_channel_id TEXT,
message_id TEXT,
questions TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS applications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
form_id INTEGER NOT NULL,
user_id TEXT NOT NULL,
username TEXT,
answers TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'open',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
const insertAppForm = db.prepare(`
INSERT INTO app_forms (title, description, review_channel_id, approve_role_id, post_channel_id, questions)
VALUES (@title, @description, @review_channel_id, @approve_role_id, @post_channel_id, @questions)
`);
const updateAppFormStmt = db.prepare(`
UPDATE app_forms SET title = @title, description = @description, review_channel_id = @review_channel_id,
approve_role_id = @approve_role_id, post_channel_id = @post_channel_id, questions = @questions
WHERE id = @id
`);
const setAppFormMessageStmt = db.prepare('UPDATE app_forms SET message_id = ? WHERE id = ?');
const getAppFormStmt = db.prepare('SELECT * FROM app_forms WHERE id = ?');
const listAppFormsStmt = db.prepare('SELECT * FROM app_forms ORDER BY id');
const deleteAppFormStmt = db.prepare('DELETE FROM app_forms WHERE id = ?');
export const createAppForm = (f) => insertAppForm.run(f).lastInsertRowid;
export const updateAppForm = (f) => updateAppFormStmt.run(f).changes > 0;
export const setAppFormMessage = (id, messageId) => setAppFormMessageStmt.run(messageId, id);
export const getAppForm = (id) => getAppFormStmt.get(id) ?? null;
export const listAppForms = () => listAppFormsStmt.all();
export const deleteAppForm = (id) => deleteAppFormStmt.run(id).changes > 0;
const insertApplication = db.prepare(`
INSERT INTO applications (form_id, user_id, username, answers) VALUES (?, ?, ?, ?)
`);
const getApplicationStmt = db.prepare('SELECT * FROM applications WHERE id = ?');
const openApplicationStmt = db.prepare(
`SELECT * FROM applications WHERE form_id = ? AND user_id = ? AND status = 'open'`
);
const setApplicationStatusStmt = db.prepare('UPDATE applications SET status = ? WHERE id = ?');
export const createApplication = (formId, userId, username, answers) =>
insertApplication.run(formId, userId, username, JSON.stringify(answers)).lastInsertRowid;
export const getApplication = (id) => getApplicationStmt.get(id) ?? null;
export const openApplicationOf = (formId, userId) => openApplicationStmt.get(formId, userId) ?? null;
export const setApplicationStatus = (id, status) => setApplicationStatusStmt.run(status, id);
// Triggers (Auto-Antworten), Reminders, Server-Aktivität, Temp-Voice
db.exec(`
CREATE TABLE IF NOT EXISTS triggers (
keyword TEXT PRIMARY KEY,
reply TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
text TEXT NOT NULL,
remind_at TEXT NOT NULL,
sent INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS activity_daily (
day TEXT PRIMARY KEY,
messages INTEGER NOT NULL DEFAULT 0,
joins INTEGER NOT NULL DEFAULT 0,
leaves INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS temp_voice (
channel_id TEXT PRIMARY KEY
);
`);
const upsertTrigger = db.prepare(`
INSERT INTO triggers (keyword, reply) VALUES (?, ?)
ON CONFLICT(keyword) DO UPDATE SET reply = excluded.reply
`);
const listTriggersStmt = db.prepare('SELECT keyword, reply FROM triggers ORDER BY keyword');
const deleteTriggerStmt = db.prepare('DELETE FROM triggers WHERE keyword = ?');
export const saveTrigger = (keyword, reply) => upsertTrigger.run(keyword, reply);
export const listTriggers = () => listTriggersStmt.all();
export const deleteTrigger = (keyword) => deleteTriggerStmt.run(keyword).changes > 0;
const insertReminder = db.prepare('INSERT INTO reminders (user_id, text, remind_at) VALUES (?, ?, ?)');
const dueRemindersStmt = db.prepare(
`SELECT * FROM reminders WHERE sent = 0 AND remind_at <= datetime('now')`
);
const markReminderSentStmt = db.prepare('UPDATE reminders SET sent = 1 WHERE id = ?');
export const createReminder = (userId, text, remindAt) => insertReminder.run(userId, text, remindAt).lastInsertRowid;
export const dueReminders = () => dueRemindersStmt.all();
export const markReminderSent = (id) => markReminderSentStmt.run(id);
const bumpActivityStmt = (col) => db.prepare(`
INSERT INTO activity_daily (day, ${col}) VALUES (date('now'), 1)
ON CONFLICT(day) DO UPDATE SET ${col} = ${col} + 1
`);
const bumpMessages = bumpActivityStmt('messages');
const bumpJoins = bumpActivityStmt('joins');
const bumpLeaves = bumpActivityStmt('leaves');
export const countActivity = (kind) =>
(kind === 'join' ? bumpJoins : kind === 'leave' ? bumpLeaves : bumpMessages).run();
const activityRangeStmt = db.prepare(
`SELECT day, messages, joins, leaves FROM activity_daily WHERE day >= date('now', ?) ORDER BY day`
);
export const activityRange = (days = 30) => activityRangeStmt.all(`-${days} days`);
const insertTempVoice = db.prepare('INSERT OR IGNORE INTO temp_voice (channel_id) VALUES (?)');
const deleteTempVoiceStmt = db.prepare('DELETE FROM temp_voice WHERE channel_id = ?');
const listTempVoiceStmt = db.prepare('SELECT channel_id FROM temp_voice');
export const trackTempVoice = (id) => insertTempVoice.run(id);
export const untrackTempVoice = (id) => deleteTempVoiceStmt.run(id);
export const listTempVoice = () => listTempVoiceStmt.all().map((r) => r.channel_id);
const isTempVoiceStmt = db.prepare('SELECT 1 FROM temp_voice WHERE channel_id = ?');
export const isTempVoice = (id) => Boolean(isTempVoiceStmt.get(id));
// Moderation: Verwarnungen + Sticky-Roles
db.exec(`
CREATE TABLE IF NOT EXISTS warns (
+2
View File
@@ -7,6 +7,7 @@ import { scheduleBackups } from './backup.js';
import { startServerMonitor } from './bot/server-monitor.js';
import { startGiveaways } from './bot/giveaways.js';
import { startScheduledPosts } from './web/scheduled-posts.js';
import { startSocialNotify } from './bot/social-notify.js';
process.on('unhandledRejection', (error) => {
console.error('[main] Unhandled Rejection:', error);
@@ -21,6 +22,7 @@ try {
startServerMonitor(client);
startGiveaways(client);
startScheduledPosts(client);
startSocialNotify(client);
} catch (error) {
console.error('[main] Start fehlgeschlagen:', error);
process.exit(1);
+36
View File
@@ -93,6 +93,42 @@ export function botStatusType() {
return getSetting('bot_status_type') || 'custom';
}
/** Bot-Presence-Status: online | idle | dnd */
export function botPresenceStatus() {
return getSetting('bot_presence_status') || 'online';
}
/** Ankündigungs-Kanal für neue Discord-Events — leer = aus */
export function eventsAnnounceChannelId() {
return getSetting('events_announce_channel_id') || null;
}
/** Announce-Kanal für Twitch/YouTube — leer = aus */
export function socialAnnounceChannelId() {
return getSetting('social_announce_channel_id') || null;
}
/** YouTube-Kanal-ID (UC…) für neue-Video-Benachrichtigungen */
export function youtubeChannelId() {
return getSetting('youtube_channel_id') || null;
}
/** Twitch-Login-Name + App-Credentials (Client-Credentials-Flow) */
export function twitchChannel() {
return getSetting('twitch_channel') || null;
}
export function twitchClientId() {
return getSetting('twitch_client_id') || null;
}
export function twitchClientSecret() {
return getSetting('twitch_client_secret') || null;
}
/** "Join to Create"-Voice-Kanal — leer = Temp-Voice aus */
export function tempVoiceChannelId() {
return getSetting('tempvoice_channel_id') || null;
}
/** Auto-Rolle für neue Member — leer = aus */
export function autoroleId() {
return getSetting('autorole_id') || null;
+223 -1
View File
@@ -7,7 +7,11 @@ import {
createRoleMenu, updateRoleMenu, getRoleMenu, listRoleMenus, deleteRoleMenu,
topLevels, saveTag, listTags, deleteTag,
createScheduledPost, listScheduledPosts, deleteScheduledPost,
addAlphaKeys, freeAlphaKeyCount, assignedAlphaKeys, alphaKeyOf, reserveAlphaKey, unreserveAlphaKey,
createAppForm, updateAppForm, getAppForm, listAppForms, deleteAppForm,
saveTrigger, listTriggers, deleteTrigger, activityRange,
} from '../db.js';
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-menus.js';
import { computeNextRun } from './scheduled-posts.js';
import { config } from '../config.js';
@@ -80,8 +84,36 @@ export function registerApiRoutes(app, client) {
app.get('/api/wishes', async () => ({ wishes: topWishes(20) }));
app.get('/api/heatmap', async () => ({ days: commitHeatmap() }));
// Level-Bestenliste — öffentlich
// Level-Bestenliste + Server-Aktivität — öffentlich
app.get('/api/levels', async () => ({ levels: topLevels(50) }));
app.get('/api/serverstats', async () => ({
days: activityRange(30),
members: [...client.guilds.cache.values()].reduce((sum, g) => sum + (g.memberCount ?? 0), 0),
}));
// Discord-Events — öffentlich (5-min-Cache)
let eventsCache = { at: 0, data: null };
app.get('/api/events', async () => {
if (eventsCache.data && Date.now() - eventsCache.at < 5 * 60_000) return eventsCache.data;
const events = [];
for (const guild of client.guilds.cache.values()) {
const fetched = await guild.scheduledEvents?.fetch?.().catch(() => null);
for (const e of fetched?.values?.() ?? []) {
events.push({
name: e.name,
description: e.description ?? '',
start: e.scheduledStartAt?.toISOString() ?? null,
end: e.scheduledEndAt?.toISOString() ?? null,
url: e.url,
cover: e.coverImageURL?.({ size: 1024 }) ?? null,
interested: e.userCount ?? null,
});
}
}
events.sort((a, b) => (a.start ?? '').localeCompare(b.start ?? ''));
eventsCache = { at: Date.now(), data: { events } };
return eventsCache.data;
});
// Roadmap — öffentlich, aus Gitea-Milestones (5-Minuten-Cache gegen API-Hammering)
let roadmapCache = { at: 0, repo: null, data: null };
@@ -234,6 +266,13 @@ ${rssItems}
levels_enabled: getSetting('levels_enabled') === '1',
levels_announce: getSetting('levels_announce') !== '0',
level_rewards: getSetting('level_rewards') ?? '',
bot_presence_status: getSetting('bot_presence_status') ?? 'online',
events_announce_channel_id: getSetting('events_announce_channel_id') ?? '',
social_announce_channel_id: getSetting('social_announce_channel_id') ?? '',
youtube_channel_id: getSetting('youtube_channel_id') ?? '',
twitch_channel: getSetting('twitch_channel') ?? '',
twitch_creds_set: Boolean(getSetting('twitch_client_id') && getSetting('twitch_client_secret')),
tempvoice_channel_id: getSetting('tempvoice_channel_id') ?? '',
};
}
@@ -247,6 +286,8 @@ ${rssItems}
settings: currentSettings(),
status: {
botTag: client.user?.tag ?? null,
botName: client.user?.username ?? null,
botAvatar: client.user?.displayAvatarURL?.({ size: 128 }) ?? null,
uptimeSeconds: Math.floor(process.uptime()),
guilds: client.guilds.cache.size,
giteaTokenConfigured: Boolean(giteaApiToken()),
@@ -266,6 +307,7 @@ ${rssItems}
'release_channel_id', 'backup_channel_id', 'starboard_channel_id',
'screenshot_channel_id', 'modmail_channel_id', 'welcome_channel_id',
'modlog_channel_id', 'status_channel_id', 'voting_channel_id', 'ticket_channel_id',
'events_announce_channel_id', 'social_announce_channel_id',
];
for (const key of ['commit_channel_id', 'devlog_channel_id', ...OPTIONAL_CHANNELS]) {
if (body[key] === undefined) continue;
@@ -351,6 +393,22 @@ ${rssItems}
setSetting('bot_status_text', String(body.bot_status_text).trim().slice(0, 120));
statusChanged = true;
}
if (body.bot_presence_status !== undefined) {
if (!['online', 'idle', 'dnd'].includes(body.bot_presence_status)) {
return reply.code(400).send({ error: 'bot_presence_status ungültig' });
}
setSetting('bot_presence_status', body.bot_presence_status);
statusChanged = true;
}
// Bot-Name (Discord rate-limitet Umbenennen stark)
if (body.bot_name !== undefined && String(body.bot_name).trim() &&
String(body.bot_name).trim() !== client.user?.username) {
try {
await client.user.setUsername(String(body.bot_name).trim().slice(0, 32));
} catch (error) {
return reply.code(502).send({ error: `Bot-Name: Discord lehnt ab (${error.message?.slice(0, 80)})` });
}
}
if (statusChanged) {
const { applyBotStatus } = await import('../bot/presence.js');
applyBotStatus(client);
@@ -362,6 +420,15 @@ ${rssItems}
if (body.gitea_api_token !== undefined && String(body.gitea_api_token).trim() !== '') {
setSetting('gitea_api_token', String(body.gitea_api_token).trim());
}
// Social + Temp-Voice
for (const key of ['youtube_channel_id', 'twitch_channel', 'tempvoice_channel_id']) {
if (body[key] !== undefined) setSetting(key, String(body[key]).trim());
}
for (const key of ['twitch_client_id', 'twitch_client_secret']) {
if (body[key] !== undefined && String(body[key]).trim() !== '') {
setSetting(key, String(body[key]).trim());
}
}
for (const key of ['commit_branch_filter', 'ignored_repos', 'bug_report_repo', 'watchdog_urls', 'roadmap_repo', 'gameservers']) {
if (body[key] !== undefined) {
setSetting(key, String(body[key]).trim());
@@ -488,6 +555,161 @@ ${rssItems}
return { deleted: true };
});
// --- Alpha-Keys (Admin) ---
const keyStatus = () => ({
free: freeAlphaKeyCount(),
assigned: assignedAlphaKeys(),
playtestersWithout: listPlaytesters().filter((p) => !alphaKeyOf(p.user_id)),
});
app.get('/api/alphakeys', async (request, reply) => {
if (requireAdmin(request, reply)) return;
return keyStatus();
});
app.post('/api/alphakeys', async (request, reply) => {
if (requireAdmin(request, reply)) return;
const keys = String(request.body?.keys ?? '')
.split(/\r?\n/)
.map((k) => k.trim())
.filter(Boolean);
if (keys.length === 0) return reply.code(400).send({ error: 'keys: eine pro Zeile' });
const added = addAlphaKeys(keys);
request.log.info(`${added} Alpha-Keys hinzugefügt`);
return { ok: true, added, ...keyStatus() };
});
// Verteilen: jeder Playtester ohne Key bekommt einen per DM (Key bleibt frei bei DM-Fehler)
app.post('/api/alphakeys/distribute', async (request, reply) => {
if (requireAdmin(request, reply)) return;
let sent = 0;
const failed = [];
for (const p of keyStatus().playtestersWithout) {
const key = reserveAlphaKey(p.user_id);
if (!key) break; // Pool leer
try {
const user = await client.users.fetch(p.user_id);
await user.send(
`🎟️ **Dein EcoGame-Alpha-Key:**\n\`\`\`\n${key}\n\`\`\`\nViel Spaß beim Testen — Feedback gern per \`/bug\` oder \`/wunsch\`! 💛`
);
sent++;
} catch {
unreserveAlphaKey(key);
failed.push(p.username ?? p.user_id);
}
}
request.log.info(`Alpha-Keys verteilt: ${sent} gesendet, ${failed.length} fehlgeschlagen`);
return { ok: true, sent, failed, ...keyStatus() };
});
// --- Bewerbungs-Formulare (Admin) ---
const formToJson = (f) => ({ ...f, questions: JSON.parse(f.questions || '[]') });
function parseFormBody(request, reply) {
const body = request.body ?? {};
const title = String(body.title ?? '').trim();
if (!title) {
reply.code(400).send({ error: 'title nötig' });
return null;
}
const questions = (Array.isArray(body.questions) ? body.questions : [])
.map((q) => String(q).trim())
.filter(Boolean)
.slice(0, MAX_QUESTIONS);
for (const key of ['review_channel_id', 'post_channel_id']) {
const v = String(body[key] ?? '');
if (v && !client.channels.cache.get(v)?.isTextBased?.()) {
reply.code(400).send({ error: `${key}: Kanal nicht gefunden` });
return null;
}
}
const roleId = String(body.approve_role_id ?? '');
if (roleId && ![...client.guilds.cache.values()].some((g) => g.roles?.cache?.has?.(roleId))) {
reply.code(400).send({ error: 'approve_role_id: Rolle nicht gefunden' });
return null;
}
return {
title: title.slice(0, 100),
description: String(body.description ?? '').trim().slice(0, 1000),
review_channel_id: String(body.review_channel_id ?? '') || null,
approve_role_id: roleId || null,
post_channel_id: String(body.post_channel_id ?? '') || null,
questions: JSON.stringify(questions),
};
}
app.get('/api/appforms', async (request, reply) => {
if (requireAdmin(request, reply)) return;
return { forms: listAppForms().map(formToJson) };
});
app.post('/api/appforms', async (request, reply) => {
if (requireAdmin(request, reply)) return;
const form = parseFormBody(request, reply);
if (!form) return;
const id = createAppForm(form);
return { ok: true, form: formToJson(getAppForm(id)) };
});
app.put('/api/appforms/:id', async (request, reply) => {
if (requireAdmin(request, reply)) return;
const id = Number(request.params.id);
if (!getAppForm(id)) return reply.code(404).send({ error: 'Formular nicht gefunden' });
const form = parseFormBody(request, reply);
if (!form) return;
updateAppForm({ ...form, id });
if (getAppForm(id).message_id) {
await publishAppForm(client, id).catch((e) =>
request.log.warn(`Formular ${id}: Discord-Update fehlgeschlagen: ${e.message}`)
);
}
return { ok: true, form: formToJson(getAppForm(id)) };
});
app.post('/api/appforms/:id/publish', async (request, reply) => {
if (requireAdmin(request, reply)) return;
try {
const messageId = await publishAppForm(client, Number(request.params.id));
return { ok: true, message_id: messageId };
} catch (error) {
return reply.code(400).send({ error: error.message });
}
});
app.delete('/api/appforms/:id', async (request, reply) => {
if (requireAdmin(request, reply)) return;
const form = getAppForm(Number(request.params.id));
if (!form) return { deleted: false };
await unpublishAppForm(client, form);
deleteAppForm(form.id);
return { deleted: true };
});
// --- Triggers (Admin) ---
app.get('/api/triggers', async (request, reply) => {
if (requireAdmin(request, reply)) return;
return { triggers: listTriggers() };
});
app.put('/api/triggers', async (request, reply) => {
if (requireAdmin(request, reply)) return;
const keyword = String(request.body?.keyword ?? '').trim().toLowerCase();
const replyText = String(request.body?.reply ?? '').trim();
if (keyword.length < 3 || !replyText) {
return reply.code(400).send({ error: 'keyword (min 3 Zeichen) und reply nötig' });
}
saveTrigger(keyword.slice(0, 60), replyText.slice(0, 2000));
return { ok: true };
});
app.delete('/api/triggers/:keyword', async (request, reply) => {
if (requireAdmin(request, reply)) return;
return { deleted: deleteTrigger(String(request.params.keyword).toLowerCase()) };
});
// --- Tags (Admin-Verwaltung; Abruf via /tag in Discord) ---
app.get('/api/tags', async (request, reply) => {