Files
2025-12-26 16:59:56 +00:00

600 lines
20 KiB
JavaScript

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();