require('dotenv').config(); const { Telegraf } = require('telegraf'); const express = require('express'); const path = require('path'); const fs = require('fs').promises; const archiver = require('archiver'); const schedule = require('node-schedule'); // ==================== KONFIGURATION ==================== const validateEnv = () => { const required = ['TELEGRAM_TOKEN', 'ALLOWED_CHAT_ID']; const missing = required.filter(key => !process.env[key]); if (missing.length > 0) { console.error(`Fehlende Umgebungsvariablen: ${missing.join(', ')}`); process.exit(1); } }; validateEnv(); const CONFIG = { token: process.env.TELEGRAM_TOKEN, allowedChatId: process.env.ALLOWED_CHAT_ID, allowedThreadId: parseInt(process.env.ALLOWED_THREAD_ID, 10) || null, adminIds: (process.env.ADM_IDS || '').split(',').map(id => id.trim()).filter(Boolean), // Blacklists blacklistChatIds: (process.env.BLACKLIST_CHAT_IDS || '').split(',').map(id => id.trim()).filter(Boolean), // WICHTIG: IDs wie 1, 646 müssen HIER stehen, damit Topics geblockt werden blacklistThreadIds: (process.env.BLACKLIST_THREAD_IDS || '').split(',').map(id => parseInt(id.trim(), 10)).filter(Boolean), logDir: path.join(__dirname, 'Log'), port: process.env.PORT || 3005, itemsPerPage: 10 }; const PATHS = { errorLog: path.join(CONFIG.logDir, 'error.log'), wishLog: path.join(CONFIG.logDir, 'wish.log'), notFoundLog: path.join(CONFIG.logDir, 'not_found.json') }; const CATEGORIES = { 'category_film': '🎬 Film', 'category_4k_filme': '📀 4K Filme', 'category_serie': '📺 Serie', 'category_anime': '📖 Anime', 'category_disney': '✨ Disney', 'category_bollywood': '🎬 Bollywood', 'category_medizin': '⚕️ Medizin', 'category_survival': '🏕️ Survival', 'category_wwe': '🏆 WWE', 'category_musik': '🎵 Musik', 'category_hoerspiele_comics': '🎧 Hörspiele & Comics', 'category_pc_games': '💻 PC Games' }; // ==================== HELPER KLASSEN ==================== class Logger { static async error(error) { const timestamp = new Date().toISOString(); const message = `${timestamp} - ${error.message}\n${error.stack}\n\n`; console.error(message); try { if (!await this.fileExists(PATHS.errorLog)) await fs.writeFile(PATHS.errorLog, ''); await fs.appendFile(PATHS.errorLog, message); } catch (err) { console.error('Kritisch: Konnte Error-Log nicht schreiben:', err.message); } } static async wish(wish, category, link) { const timestamp = new Date().toISOString(); const message = `${timestamp} - Kategorie: ${category} - Wunsch: ${wish} - Link: ${link || 'Kein Link'}\n\n`; try { if (!await this.fileExists(PATHS.wishLog)) await fs.writeFile(PATHS.wishLog, ''); await fs.appendFile(PATHS.wishLog, message); } catch (err) { console.error('Fehler beim Schreiben in wish.log:', err.message); } } static async fileExists(filePath) { try { await fs.access(filePath); return true; } catch { return false; } } } class NotFoundManager { static async get() { try { const data = await fs.readFile(PATHS.notFoundLog, 'utf8'); return JSON.parse(data); } catch (error) { if (error.code !== 'ENOENT') console.error('Fehler Lesen not_found.json:', error); return []; } } static async add(wish, category) { try { const wishes = await this.get(); wishes.push({ wish, category, timestamp: new Date().toISOString() }); await fs.writeFile(PATHS.notFoundLog, JSON.stringify(wishes, null, 2)); } catch (error) { console.error('Fehler Speichern not_found:', error); throw error; } } static async delete(index) { try { const wishes = await this.get(); if (index >= 0 && index < wishes.length) { wishes.splice(index, 1); await fs.writeFile(PATHS.notFoundLog, JSON.stringify(wishes, null, 2)); return true; } return false; } catch (error) { console.error('Fehler Löschen not_found:', error); return false; } } } class SessionManager { constructor() { this.states = {}; } set(chatId, data) { this.states[chatId] = data; } get(chatId) { return this.states[chatId]; } delete(chatId) { delete this.states[chatId]; } async clearChat(ctx, chatId) { const state = this.get(chatId); if (!state) return; const idsToDelete = [ state.commandMessageId, state.uiMessageId, state.wishCommandMessageId, state.titleRequestMessageId ].filter(id => id); for (const msgId of idsToDelete) { try { await ctx.deleteMessage(msgId); } catch (err) {} } this.delete(chatId); } } // ==================== EXPRESS & BOT SETUP ==================== const app = express(); app.use(express.json()); app.use(express.static(path.join(__dirname, 'public'))); const bot = new Telegraf(CONFIG.token); const sessions = new SessionManager(); // ==================== MIDDLEWARES & HELPER ==================== // 1. GLOBALE BLACKLIST (Läuft für JEDE Nachricht und JEDES Update) const globalRestrictionCheck = (ctx, next) => { // Wir müssen Chat und Thread ID aus verschiedenen Quellen holen // (Message, Edited Message, Callback Query) const chatId = ctx.chat?.id.toString(); // Thread ID kann in message.message_thread_id oder callbackQuery.message.message_thread_id stehen const threadId = ctx.message?.message_thread_id || ctx.callbackQuery?.message?.message_thread_id; // 1. Chat Blacklist if (chatId && CONFIG.blacklistChatIds.includes(chatId)) { console.log(`🛑 GLOBALE BLOCKADE: Chat ID ${chatId} ist gesperrt.`); return; // Sofort stoppen } // 2. Thread Blacklist (Fix für ID 1, 646, etc.) // Wir prüfen nur, wenn eine threadId existiert (Topic) if (threadId && CONFIG.blacklistThreadIds.includes(threadId)) { console.log(`🛑 GLOBALE BLOCKADE: Thread ID ${threadId} ist auf der Blacklist.`); return; // Sofort stoppen } // Wenn alles okay ist, geht es weiter return next(); }; // Middleware global anwenden bot.use(globalRestrictionCheck); const getReplyOptions = (ctx, extra = {}) => { const threadId = ctx.message?.message_thread_id || ctx.callbackQuery?.message?.message_thread_id; if (threadId) { return { ...extra, message_thread_id: threadId }; } return extra; }; // 3. STRICT MODE (Wenn ALLOWED_THREAD_ID gesetzt ist) const restrictToConfiguredThread = (ctx, next) => { const chatId = ctx.chat?.id.toString(); const threadId = ctx.message?.message_thread_id; // Wenn wir nicht im erlaubten Chat sind, machen wir nix (außer Blacklist fängt das schon ab) if (chatId !== CONFIG.allowedChatId) { return; } // Wenn eine explizite ALLOWED_THREAD_ID gesetzt ist, muss sie stimmen if (CONFIG.allowedThreadId && threadId !== CONFIG.allowedThreadId) { console.log(`⚠️ IGNORIERT (Strict Mode): Falscher Thread. Ziel=${CONFIG.allowedThreadId}, Ist=${threadId}`); return; } console.log(`✅ AKZEPTIERT: Cmd=${ctx.message.text} Thread=${threadId}`); return next(); }; const isAdmin = (ctx, next) => { const userId = ctx.from?.id.toString(); if (CONFIG.adminIds.includes(userId)) { return next(); } return; }; // ==================== BOT LOGIK ==================== const deleteMessageSafe = async (ctx, msgId) => { try { await ctx.deleteMessage(msgId); } catch (e) {} }; const sendWishToGroup = async (wish, category, link) => { const msg = `🎬 *Ein neuer Wunsch ist eingegangen!*\n\n🔹 Kategorie: ${category}\n\n🔸 Titel: ${wish}\n\n🔗 Link: ${link || 'Kein Link'}`; await Logger.wish(wish, category, link); await bot.telegram.sendMessage(CONFIG.allowedChatId, msg, { message_thread_id: CONFIG.allowedThreadId, parse_mode: 'Markdown', reply_markup: { inline_keyboard: [ [{ text: '✅ Erledigt', callback_data: 'wish_fulfilled' }], [{ text: '❌ Nicht gefunden', callback_data: 'wish_not_found' }] ] } }); }; const getCategoryKeyboard = () => { const buttons = Object.entries(CATEGORIES).map(([key, label]) => ({ text: label, callback_data: key })); const chunks = []; for (let i = 0; i < buttons.length; i += 2) { chunks.push(buttons.slice(i, i + 2)); } return { reply_markup: { inline_keyboard: chunks } }; }; // --- HILFSFUNKTION PAGINATION --- async function sendNotFoundPage(ctx, page = 0) { const userId = ctx.from.id; const list = await NotFoundManager.get(); const totalItems = list.length; const totalPages = Math.ceil(totalItems / CONFIG.itemsPerPage); if (page >= totalPages && totalPages > 0) page = totalPages - 1; const start = page * CONFIG.itemsPerPage; const end = start + CONFIG.itemsPerPage; const currentItems = list.slice(start, end); let text = `🔍 *Liste der nicht gefundenen Wünsche (${totalItems} Einträge)*\n`; text += `📄 Seite ${page + 1} / ${totalPages || 1}\n\n`; const keyboard = []; currentItems.forEach((item, index) => { const globalIndex = start + index; const displayTitle = item.wish.length > 25 ? item.wish.substring(0, 22) + '...' : item.wish; text += `${globalIndex + 1}. [${item.category}] ${displayTitle}\n`; }); const deleteButtons = []; currentItems.forEach((item, index) => { const globalIndex = start + index; deleteButtons.push({ text: `🗑️ #${globalIndex + 1}`, callback_data: `del_${globalIndex}_${page}` }); }); const chunks = []; for (let i = 0; i < deleteButtons.length; i += 3) { chunks.push(deleteButtons.slice(i, i + 3)); } keyboard.push(...chunks); const navRow = []; if (page > 0) navRow.push({ text: '⬅️ Zurück', callback_data: `page_${page - 1}` }); navRow.push({ text: `📄 ${page + 1}/${totalPages || 1}`, callback_data: 'ignore' }); if (page < totalPages - 1) navRow.push({ text: '➡️ Weiter', callback_data: `page_${page + 1}` }); if (navRow.length > 0) keyboard.push(navRow); try { if (ctx.callbackQuery) { await ctx.editMessageText(text, { parse_mode: 'Markdown', reply_markup: { inline_keyboard: keyboard } }); } else { await bot.telegram.sendMessage(userId, text, { parse_mode: 'Markdown', reply_markup: { inline_keyboard: keyboard } }); if (ctx.chat && ctx.chat.id.toString() !== userId.toString()) { await ctx.reply('📬 Liste per PN geschickt.', getReplyOptions(ctx)); } } } catch (err) { console.error('Error rendering page:', err); } } // --- COMMANDS --- // /start, /help, /info haben KEIN restrictToConfiguredThread, funktionieren also überall // AUCH nicht in geblockten Threads, weil globalRestrictionCheck davor sitzt. bot.command('start', (ctx) => ctx.reply('Willkommen! Nutze /wunsch um einen zu starten.', getReplyOptions(ctx))); bot.command('help', (ctx) => ctx.reply( `📋 *Hilfemenü*\n\n` + `1️⃣ /wunsch - Starte einen Wunsch\n` + `2️⃣ Kategorie wählen\n` + `3️⃣ Optional: Link senden (oder "x")\n` + `4️⃣ Titel eingeben`, { ...getReplyOptions(ctx), parse_mode: 'Markdown' } )); bot.command('info', (ctx) => ctx.reply( `🤖 *Bot-Informationen*\n\n` + `Version: 2.0 (Strict Global Blacklist)\n` + `Status: Laufend`, { ...getReplyOptions(ctx), parse_mode: 'Markdown' } )); // Wichtig: /wunsch, /cancel, /notfound laufen NUR im erlaubten Thread bot.command('wunsch', restrictToConfiguredThread, async (ctx) => { const chatId = ctx.chat.id.toString(); await sessions.clearChat(ctx, chatId); const msg = await ctx.reply('Möchtest du etwas wünschen? Wähle eine Kategorie.', { ...getCategoryKeyboard(), ...getReplyOptions(ctx), disable_notification: true }); sessions.set(chatId, { waitingFor: 'category', commandMessageId: ctx.message.message_id, uiMessageId: msg.message_id }); }); bot.command('cancel', restrictToConfiguredThread, async (ctx) => { const chatId = ctx.chat.id.toString(); const session = sessions.get(chatId); if (session) { await sessions.clearChat(ctx, chatId); await deleteMessageSafe(ctx, ctx.message.message_id); const abortMsg = await ctx.reply('🔴 Vorgang abgebrochen.', { ...getReplyOptions(ctx), disable_notification: true }); setTimeout(() => deleteMessageSafe(ctx, abortMsg.message_id), 3000); } else { await deleteMessageSafe(ctx, ctx.message.message_id); } }); bot.command('notfound', restrictToConfiguredThread, isAdmin, async (ctx) => { await sendNotFoundPage(ctx, 0); }); // --- TEXT HANDLER --- bot.on('text', restrictToConfiguredThread, async (ctx) => { const chatId = ctx.chat.id.toString(); const session = sessions.get(chatId); const text = ctx.message.text.trim(); if (!session) return; try { if (session.waitingFor === 'link') { const link = text.toLowerCase() === 'x' ? null : text; if (session.uiMessageId) await deleteMessageSafe(ctx, session.uiMessageId); await deleteMessageSafe(ctx, ctx.message.message_id); sessions.set(chatId, { ...session, link, waitingFor: 'title' }); const newMsg = await ctx.reply(`Bitte gib den Titel für ${session.category} ein:`, { ...getReplyOptions(ctx), disable_notification: true }); sessions.set(chatId, { ...session, link, waitingFor: 'title', uiMessageId: newMsg.message_id }); } else if (session.waitingFor === 'title') { const wish = text; if (!wish) return ctx.reply('Bitte einen Titel eingeben.', getReplyOptions(ctx)); await sendWishToGroup(wish, session.category, session.link); await sessions.clearChat(ctx, chatId); await deleteMessageSafe(ctx, ctx.message.message_id); const successMsg = `✅ *Dein Wunsch wurde erfolgreich weitergeleitet!*\n\n🔹 Kategorie: ${session.category}\n🔸 Titel: ${wish}`; const confirm = await ctx.reply(successMsg, { ...getReplyOptions(ctx), parse_mode: 'Markdown', disable_notification: true }); setTimeout(() => deleteMessageSafe(ctx, confirm.message_id), 5000); } } catch (error) { await Logger.error(error); ctx.reply('❌ Ein Fehler ist aufgetreten.', getReplyOptions(ctx)); await sessions.clearChat(ctx, chatId); } }); // --- CALLBACK HANDLER --- bot.on('callback_query', async (ctx) => { const data = ctx.callbackQuery.data; const userId = ctx.from.id; if (data.startsWith('category_')) { const category = CATEGORIES[data]; const chatId = ctx.chat?.id.toString(); if (category && chatId) { const session = sessions.get(chatId) || {}; if (session.uiMessageId) await deleteMessageSafe(ctx, session.uiMessageId); const msg = await ctx.reply( `Kategorie: *${category}*\n\nBitte sende mir einen Link (Cover/Spotify) oder "x":`, { ...getReplyOptions(ctx), parse_mode: 'Markdown', disable_notification: true } ); sessions.set(chatId, { ...session, category, waitingFor: 'link', uiMessageId: msg.message_id }); } await ctx.answerCbQuery(); return; } if (data.startsWith('page_')) { if (!CONFIG.adminIds.includes(userId.toString())) return ctx.answerCbQuery('Keine Berechtigung'); const page = parseInt(data.split('_')[1]); await sendNotFoundPage(ctx, page); await ctx.answerCbQuery(); return; } if (data === 'ignore') { await ctx.answerCbQuery(); return; } if (data.startsWith('del_')) { if (!CONFIG.adminIds.includes(userId.toString())) return ctx.answerCbQuery('Keine Berechtigung'); const parts = data.split('_'); const indexToDelete = parseInt(parts[1]); const currentPage = parseInt(parts[2]); const success = await NotFoundManager.delete(indexToDelete); if (success) { await ctx.answerCbQuery(`Eintrag #${indexToDelete + 1} gelöscht`); await sendNotFoundPage(ctx, currentPage); } else { await ctx.answerCbQuery('Fehler beim Löschen'); } return; } if (data === 'wish_fulfilled' || data === 'wish_not_found') { if (!CONFIG.adminIds.includes(userId.toString())) return ctx.answerCbQuery('Keine Berechtigung'); if (data === 'wish_fulfilled') { await ctx.editMessageText('✨ *Wunsch wurde erfüllt!* ✨\n🎉 Der Inhalt ist verfügbar.', { parse_mode: 'Markdown' }); } else if (data === 'wish_not_found') { const msgText = ctx.callbackQuery.message.text; const wishTitleMatch = msgText.match(/🔸 Titel: (.*)/); const title = wishTitleMatch ? wishTitleMatch[1] : 'Unbekannt'; const catMatch = msgText.match(/🔹 Kategorie: (.*)/); const category = catMatch ? catMatch[1] : 'Allgemein'; await NotFoundManager.add(title, category); await ctx.editMessageText(`☹️ Zu "${title}" wurde nichts gefunden.\nAuf die "Not Found" Liste gesetzt.`); } await ctx.answerCbQuery(); return; } }); bot.catch((err, ctx) => { Logger.error(err); console.error(`Error for ${ctx.updateType}`, err); }); // ==================== EXPRESS ROUTES ==================== app.post('/api/sendWish', async (req, res) => { const { category, link, title } = req.body; if (!category || !title) return res.status(400).json({ error: 'Kategorie und Titel sind erforderlich.' }); try { await sendWishToGroup(title, category, link || null); res.status(200).json({ success: true }); } catch (error) { Logger.error(error); res.status(500).json({ error: 'Interner Serverfehler' }); } }); // ==================== INIT & START ==================== async function init() { try { await fs.mkdir(CONFIG.logDir, { recursive: true }); const files = [PATHS.errorLog, PATHS.wishLog, PATHS.notFoundLog]; for (const file of files) { try { await fs.access(file); } catch { await fs.writeFile(file, file.endsWith('.json') ? '[]' : ''); } } } catch (error) { console.error('Init Error:', error); process.exit(1); } schedule.scheduleJob('0 0 * * *', async () => { const dateStr = new Date().toISOString().split('T')[0]; const zipPath = path.join(CONFIG.logDir, `logs_${dateStr}.zip`); const output = require('fs').createWriteStream(zipPath); const archive = archiver('zip', { zlib: { level: 9 } }); output.on('close', async () => { console.log(`Logs archiviert: ${zipPath}`); try { const files = await fs.readdir(CONFIG.logDir); const zips = files.filter(f => f.endsWith('.zip')).sort(); for (let i = 0; i < zips.length - 4; i++) { await fs.unlink(path.join(CONFIG.logDir, zips[i])); } } catch (err) { Logger.error(err); } }); archive.on('error', (err) => Logger.error(err)); archive.pipe(output); [PATHS.errorLog, PATHS.wishLog].forEach(async (filePath) => { try { await fs.access(filePath); archive.file(filePath, { name: path.basename(filePath) }); } catch (e) {} }); archive.finalize(); }); app.listen(CONFIG.port, () => console.log(`🌐 Server läuft auf Port ${CONFIG.port}`)); await bot.launch(); console.log('✅ Bot gestartet'); process.once('SIGINT', () => bot.stop('SIGINT')); process.once('SIGTERM', () => bot.stop('SIGTERM')); } init();