Files
telegram-film-wunsch-bot/wunsch-bot.js
2025-12-26 16:59:56 +00:00

600 lines
20 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();