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