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:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user