""" 🎌 Manga Translator Bot — حرفه‌ای • Gemini 2.0 Flash Vision • لاگ کامل در ترمینال + گزارش لحظه‌ای برای کاربر • منوی inline هنگام /start • نوار پیشرفت زنده • خروجی TXT """ from __future__ import annotations import os, logging, asyncio, base64, json, re, io, time, sys from dataclasses import dataclass, field import fitz # PyMuPDF import google.generativeai as genai from telegram import ( Update, InlineKeyboardButton, InlineKeyboardMarkup, BotCommand, InputFile, ) from telegram.ext import ( Application, CommandHandler, MessageHandler, CallbackQueryHandler, ContextTypes, filters, ) from telegram.constants import ParseMode, ChatAction from telegram.error import RetryAfter, TimedOut # ══════════════════════════════════════════════════════════════════ # 📋 LOGGING — رنگی در ترمینال، ذخیره در فایل # ══════════════════════════════════════════════════════════════════ LOG_FILE = "mangabot.log" # Formatter با رنگ برای ترمینال class ColorFormatter(logging.Formatter): COLORS = { logging.DEBUG: "\033[36m", # cyan logging.INFO: "\033[32m", # green logging.WARNING: "\033[33m", # yellow logging.ERROR: "\033[31m", # red logging.CRITICAL: "\033[35m", # magenta } RESET = "\033[0m" FMT = "%(asctime)s [%(levelname)-8s] %(name)s › %(message)s" def format(self, record): color = self.COLORS.get(record.levelno, "") formatter = logging.Formatter(f"{color}{self.FMT}{self.RESET}", datefmt="%H:%M:%S") return formatter.format(record) def setup_logging() -> logging.Logger: log = logging.getLogger("MangaBot") log.setLevel(logging.DEBUG) # ترمینال sh = logging.StreamHandler(sys.stdout) sh.setLevel(logging.DEBUG) sh.setFormatter(ColorFormatter()) log.addHandler(sh) # فایل fh = logging.FileHandler(LOG_FILE, encoding="utf-8") fh.setLevel(logging.INFO) fh.setFormatter(logging.Formatter( "%(asctime)s [%(levelname)-8s] %(name)s › %(message)s", datefmt="%Y-%m-%d %H:%M:%S", )) log.addHandler(fh) # suppress noisy libs logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("telegram").setLevel(logging.WARNING) logging.getLogger("google").setLevel(logging.WARNING) return log logger = setup_logging() # ══════════════════════════════════════════════════════════════════ # ⚙️ CONFIG # ══════════════════════════════════════════════════════════════════ TELEGRAM_TOKEN = os.environ["TELEGRAM_BOT_TOKEN"] GEMINI_API_KEY = os.environ["GEMINI_API_KEY"] MAX_FILE_MB = 50 RETRY_ATTEMPTS = 3 DEFAULT_DPI = 150 DPI_MAP = {"🏃 سریع (120)": 120, "✨ معمولی (150)": 150, "🔬 HD (200)": 200} genai.configure(api_key=GEMINI_API_KEY) GEMINI = genai.GenerativeModel( "gemini-2.0-flash", generation_config=genai.GenerationConfig( temperature=0.1, max_output_tokens=4096, response_mime_type="application/json", ), ) logger.info("✅ Gemini configured: gemini-2.0-flash") # ══════════════════════════════════════════════════════════════════ # 🌐 LANGUAGES # ══════════════════════════════════════════════════════════════════ LANGUAGES = { "🇮🇷 فارسی": "Persian (Farsi)", "🇺🇸 English": "English", "🇯🇵 日本語": "Japanese", "🇰🇷 한국어": "Korean", "🇩🇪 Deutsch": "German", "🇫🇷 Français": "French", "🇪🇸 Español": "Spanish", "🇮🇹 Italiano": "Italian", "🇧🇷 Português": "Portuguese", "🇷🇺 Русский": "Russian", "🇨🇳 中文": "Chinese Simplified", "🇸🇦 العربية": "Arabic", "🇹🇷 Türkçe": "Turkish", "🇳🇱 Nederlands": "Dutch", } # ══════════════════════════════════════════════════════════════════ # 📦 SESSION # ══════════════════════════════════════════════════════════════════ @dataclass class Session: pdf_bytes: bytes = b"" pdf_name: str = "manga.pdf" total_pages: int = 0 lang: str = "" dpi: int = DEFAULT_DPI cancel: bool = False history: list[dict] = field(default_factory=list) sessions: dict[int, Session] = {} def get_session(chat_id: int) -> Session: if chat_id not in sessions: sessions[chat_id] = Session() return sessions[chat_id] # ══════════════════════════════════════════════════════════════════ # 🛠️ HELPERS # ══════════════════════════════════════════════════════════════════ def esc(text: str) -> str: """Escape MarkdownV2 special chars.""" for ch in r"\_*[]()~`>#+-=|{}.!": text = text.replace(ch, f"\\{ch}") return text def progress_bar(done: int, total: int, width: int = 12) -> str: filled = int(width * done / max(total, 1)) bar = "█" * filled + "░" * (width - filled) pct = int(100 * done / max(total, 1)) return bar, pct def eta_str(elapsed: float, done: int, total: int) -> str: if done == 0: return "—" per = elapsed / done rem = int(per * (total - done)) return f"{rem//60}m{rem%60:02d}s" if rem >= 60 else f"{rem}s" # ══════════════════════════════════════════════════════════════════ # 📄 PDF # ══════════════════════════════════════════════════════════════════ def pdf_page_count(data: bytes) -> int: doc = fitz.open(stream=data, filetype="pdf") n = doc.page_count doc.close() return n def render_page(data: bytes, idx: int, dpi: int) -> bytes: doc = fitz.open(stream=data, filetype="pdf") page = doc[idx] mat = fitz.Matrix(dpi / 72, dpi / 72) pix = page.get_pixmap(matrix=mat, colorspace=fitz.csRGB, alpha=False) jpg = pix.tobytes("jpeg", jpg_quality=90) doc.close() return jpg # ══════════════════════════════════════════════════════════════════ # 🤖 GEMINI TRANSLATION # ══════════════════════════════════════════════════════════════════ PROMPT = """You are a professional manga translator. Carefully analyze this manga page and: 1. Find ALL text: speech bubbles, thought bubbles, sound effects, captions, signs. 2. Translate every piece into **{lang}** — preserve emotion, tone, and manga style. 3. For Persian (فارسی): write natural right-to-left text with proper Persian anime vocabulary. 4. For sound effects: give culturally equivalent term or describe in parentheses. Return ONLY a valid JSON array (no markdown fences): [ {{"type":"speech_bubble","original":"...","translation":"...","position":"top-left"}}, {{"type":"thought_bubble","original":"...","translation":"...","position":"center"}}, {{"type":"sound_effect","original":"...","translation":"...","position":"bottom"}}, {{"type":"caption","original":"...","translation":"...","position":"top"}}, {{"type":"sign","original":"...","translation":"...","position":"right"}} ] If no text exists on this page return exactly: []""" ICONS = { "speech_bubble": "💬", "thought_bubble": "💭", "sound_effect": "💥", "caption": "📝", "sign": "🪧", } async def translate_page(jpeg: bytes, lang: str, attempt: int = 0) -> list[dict]: logger.debug(f" → Gemini call (attempt {attempt+1})") img_part = {"mime_type": "image/jpeg", "data": base64.b64encode(jpeg).decode()} prompt = PROMPT.format(lang=lang) try: resp = await asyncio.to_thread(GEMINI.generate_content, [prompt, img_part]) raw = resp.text.strip() raw = re.sub(r"```json|```", "", raw).strip() raw = re.sub(r",\s*]", "]", raw) items = json.loads(raw) logger.debug(f" ✓ {len(items)} items translated") return items except json.JSONDecodeError as e: logger.warning(f" JSON parse error: {e} — raw: {raw[:120]}") if attempt < RETRY_ATTEMPTS - 1: await asyncio.sleep(2 ** attempt) return await translate_page(jpeg, lang, attempt + 1) return [] except Exception as e: logger.error(f" Gemini error: {e}") if attempt < RETRY_ATTEMPTS - 1: await asyncio.sleep(2 ** attempt) return await translate_page(jpeg, lang, attempt + 1) raise def format_page(items: list[dict], page_num: int, total: int, lang: str) -> str: is_fa = "Persian" in lang or "Farsi" in lang hdr = f"📄 *{'صفحه' if is_fa else 'Page'} {page_num}/{total}*\n" if not items: return hdr + "\n_\\(متنی یافت نشد\\)_" if is_fa else hdr + "\n_\\(No text found\\)_" lines = [hdr] for item in items: icon = ICONS.get(item.get("type", ""), "📌") orig = esc(item.get("original", "").replace("`","'")) trans = esc(item.get("translation", "")) pos = esc(item.get("position", "")) if not orig and not trans: continue lines.append(f"{icon} `{item.get('original','')}`") lines.append(f" ➜ *{trans}*" + (f" _\\({pos}\\)_" if pos else "")) lines.append("") return "\n".join(lines) def build_txt(history: list[dict], lang: str, pdf_name: str) -> str: lines = [ f"{'='*60}", f"ترجمه مانگا: {pdf_name}", f"زبان: {lang}", f"تاریخ: {time.strftime('%Y-%m-%d %H:%M')}", f"{'='*60}\n", ] for entry in history: lines.append(f"\n--- صفحه {entry['page']} ---") for item in entry.get("items", []): t = item.get("type","") o = item.get("original","") tr= item.get("translation","") lines.append(f"[{t}] {o} → {tr}") return "\n".join(lines) # ══════════════════════════════════════════════════════════════════ # 📤 SAFE SEND (auto-retry on flood / timeout) # ══════════════════════════════════════════════════════════════════ async def safe_edit(msg, text: str, **kw): for _ in range(3): try: await msg.edit_text(text, **kw) return except RetryAfter as e: await asyncio.sleep(e.retry_after + 1) except TimedOut: await asyncio.sleep(2) except Exception: return async def safe_send(bot, chat_id: int, text: str, **kw): for _ in range(3): try: return await bot.send_message(chat_id, text, **kw) except RetryAfter as e: await asyncio.sleep(e.retry_after + 1) except TimedOut: await asyncio.sleep(2) except Exception as ex: logger.warning(f"send failed: {ex}") return None # ══════════════════════════════════════════════════════════════════ # 🎯 KEYBOARDS # ══════════════════════════════════════════════════════════════════ def start_menu_keyboard() -> InlineKeyboardMarkup: """منوی اصلی که هنگام /start نمایش داده می‌شود.""" return InlineKeyboardMarkup([ [InlineKeyboardButton("📤 ارسال PDF و شروع ترجمه", callback_data="menu:howto")], [ InlineKeyboardButton("⚙️ تنظیم کیفیت", callback_data="menu:quality"), InlineKeyboardButton("📖 راهنما", callback_data="menu:help"), ], [InlineKeyboardButton("📚 تاریخچه ترجمه‌ها", callback_data="menu:history")], ]) def lang_keyboard() -> InlineKeyboardMarkup: rows = [] items = list(LANGUAGES.items()) for i in range(0, len(items), 2): row = [InlineKeyboardButton(lbl, callback_data=f"lang:{val}") for lbl, val in items[i:i+2]] rows.append(row) return InlineKeyboardMarkup(rows) def quality_keyboard() -> InlineKeyboardMarkup: return InlineKeyboardMarkup([ [InlineKeyboardButton(lbl, callback_data=f"dpi:{dpi}")] for lbl, dpi in DPI_MAP.items() ] + [[InlineKeyboardButton("🔙 بازگشت", callback_data="menu:back")]]) # ══════════════════════════════════════════════════════════════════ # 🤖 COMMAND HANDLERS # ══════════════════════════════════════════════════════════════════ async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE): chat_id = update.effective_chat.id user = update.effective_user logger.info(f"[/start] user={user.id} name={user.first_name}") # reset session sessions[chat_id] = Session() await update.message.reply_text( "🎌 *ربات حرفه‌ای ترجمه مانگا*\n\n" "با Gemini 2\\.0 Flash Vision\n\n" "یکی از گزینه‌های زیر رو انتخاب کن 👇", parse_mode=ParseMode.MARKDOWN_V2, reply_markup=start_menu_keyboard(), ) async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE): logger.info(f"[/help] user={update.effective_user.id}") await update.message.reply_text( "📖 *راهنما*\n\n" "۱\\. /start — منوی اصلی\n" "۲\\. فایل PDF مانگا رو بفرست\n" "۳\\. زبان مقصد رو انتخاب کن\n" "۴\\. ترجمه صفحه به صفحه دریافت کن\n\n" "*/cancel* — لغو ترجمه فعلی\n" "*/history* — تاریخچه ترجمه‌ها\n\n" "✅ پشتیبانی: حباب گفتگو 💬، حباب فکر 💭، افکت صوتی 💥، کپشن 📝، تابلو 🪧", parse_mode=ParseMode.MARKDOWN_V2, ) async def cmd_cancel(update: Update, ctx: ContextTypes.DEFAULT_TYPE): chat_id = update.effective_chat.id logger.info(f"[/cancel] user={update.effective_user.id}") s = sessions.get(chat_id) if s and s.pdf_bytes: s.cancel = True await update.message.reply_text("🛑 درخواست لغو ثبت شد\\.", parse_mode=ParseMode.MARKDOWN_V2) else: await update.message.reply_text("⚠️ ترجمه‌ای در حال اجرا نیست\\.", parse_mode=ParseMode.MARKDOWN_V2) async def cmd_history(update: Update, ctx: ContextTypes.DEFAULT_TYPE): chat_id = update.effective_chat.id logger.info(f"[/history] user={update.effective_user.id}") s = sessions.get(chat_id) if not s or not s.history: await update.message.reply_text("📭 هنوز ترجمه‌ای انجام نشده\\.", parse_mode=ParseMode.MARKDOWN_V2) return lines = ["📚 *تاریخچه ترجمه‌ها:*\n"] for i, entry in enumerate(s.history[-10:], 1): n = entry.get("page", "?") lng = esc(entry.get("lang", "?")) cnt = len(entry.get("items", [])) lines.append(f"{i}\\. صفحه {n} — {lng} — {cnt} آیتم") await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.MARKDOWN_V2) # ══════════════════════════════════════════════════════════════════ # 📎 PDF HANDLER # ══════════════════════════════════════════════════════════════════ async def handle_pdf(update: Update, ctx: ContextTypes.DEFAULT_TYPE): chat_id = update.effective_chat.id doc = update.message.document user = update.effective_user logger.info(f"[PDF] user={user.id} file='{doc.file_name}' size={doc.file_size//1024}KB") if not doc.file_name.lower().endswith(".pdf"): logger.warning(f"[PDF] rejected — not a PDF") await update.message.reply_text("⚠️ فقط فایل PDF قبول می‌کنم\\.", parse_mode=ParseMode.MARKDOWN_V2) return if doc.file_size > MAX_FILE_MB * 1024 * 1024: logger.warning(f"[PDF] rejected — too large ({doc.file_size//1024//1024}MB)") await update.message.reply_text( f"⚠️ فایل خیلی بزرگه\\. حداکثر {MAX_FILE_MB} مگابایت\\.", parse_mode=ParseMode.MARKDOWN_V2, ) return # ── Step 1: downloading ──────────────────────────────────── status = await update.message.reply_text( "⏳ *مرحله ۱/۳ — دانلود فایل\\.\\.\\.*", parse_mode=ParseMode.MARKDOWN_V2, ) logger.info("[PDF] downloading from Telegram...") try: t0 = time.time() tg_file = await ctx.bot.get_file(doc.file_id) pdf_bytes = bytes(await tg_file.download_as_bytearray()) dl_time = round(time.time() - t0, 1) logger.info(f"[PDF] downloaded in {dl_time}s — {len(pdf_bytes)//1024}KB") except Exception as e: logger.error(f"[PDF] download failed: {e}") await safe_edit(status, f"❌ خطا در دانلود: {esc(str(e))}", parse_mode=ParseMode.MARKDOWN_V2) return # ── Step 2: counting pages ───────────────────────────────── await safe_edit( status, "🔍 *مرحله ۲/۳ — بررسی فایل PDF\\.\\.\\.*", parse_mode=ParseMode.MARKDOWN_V2, ) try: n = await asyncio.to_thread(pdf_page_count, pdf_bytes) logger.info(f"[PDF] total pages: {n}") except Exception as e: logger.error(f"[PDF] page count failed: {e}") await safe_edit(status, "❌ فایل PDF معتبر نیست\\.", parse_mode=ParseMode.MARKDOWN_V2) return # ── Step 3: ready ────────────────────────────────────────── s = get_session(chat_id) s.pdf_bytes = pdf_bytes s.pdf_name = doc.file_name s.total_pages = n s.cancel = False s.history = [] await safe_edit( status, f"✅ *مرحله ۳/۳ — آماده\\!*\n\n" f"📄 تعداد صفحات: *{n}*\n" f"📁 فایل: `{esc(doc.file_name)}`\n" f"⏬ دانلود در: {dl_time} ثانیه\n\n" f"👇 زبان مقصد رو انتخاب کن:", parse_mode=ParseMode.MARKDOWN_V2, reply_markup=lang_keyboard(), ) logger.info(f"[PDF] session ready — waiting for language selection") # ══════════════════════════════════════════════════════════════════ # 🎛️ CALLBACK HANDLER # ══════════════════════════════════════════════════════════════════ async def handle_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE): query = update.callback_query await query.answer() chat_id = update.effective_chat.id data = query.data user = update.effective_user logger.info(f"[CB] user={user.id} data='{data}'") # ── Menu actions ──────────────────────────────────────────── if data == "menu:howto": await query.edit_message_text( "📤 *نحوه شروع:*\n\n" "فقط یک فایل PDF مانگا برام بفرست\\!\n" "بعد از دریافت، زبان مقصد رو انتخاب می‌کنی\n" "و ترجمه شروع می‌شه 🚀", parse_mode=ParseMode.MARKDOWN_V2, reply_markup=InlineKeyboardMarkup([[ InlineKeyboardButton("🔙 بازگشت", callback_data="menu:back") ]]), ) return if data == "menu:help": await query.edit_message_text( "📖 *راهنما*\n\n" "*/start* — منوی اصلی\n" "*/cancel* — لغو ترجمه\n" "*/history* — تاریخچه\n\n" "✅ پشتیبانی از ۱۴ زبان\n" "💬 حباب گفتگو \\| 💭 حباب فکر\n" "💥 افکت صوتی \\| 📝 کپشن \\| 🪧 تابلو", parse_mode=ParseMode.MARKDOWN_V2, reply_markup=InlineKeyboardMarkup([[ InlineKeyboardButton("🔙 بازگشت", callback_data="menu:back") ]]), ) return if data == "menu:quality": s = get_session(chat_id) await query.edit_message_text( f"⚙️ *تنظیم کیفیت رندر*\n\n" f"کیفیت فعلی: *{s.dpi} DPI*\n" f"_کیفیت بالاتر = دقت بیشتر، زمان بیشتر_", parse_mode=ParseMode.MARKDOWN_V2, reply_markup=quality_keyboard(), ) return if data == "menu:history": s = sessions.get(chat_id) if not s or not s.history: await query.edit_message_text( "📭 هنوز ترجمه‌ای انجام نشده\\.", parse_mode=ParseMode.MARKDOWN_V2, reply_markup=InlineKeyboardMarkup([[ InlineKeyboardButton("🔙 بازگشت", callback_data="menu:back") ]]), ) else: lines = ["📚 *آخرین ترجمه‌ها:*\n"] for e in s.history[-8:]: lines.append(f"• صفحه {e['page']} — {esc(e['lang'])} — {len(e['items'])} آیتم") await query.edit_message_text( "\n".join(lines), parse_mode=ParseMode.MARKDOWN_V2, reply_markup=InlineKeyboardMarkup([[ InlineKeyboardButton("🔙 بازگشت", callback_data="menu:back") ]]), ) return if data == "menu:back": sessions[chat_id] = sessions.get(chat_id, Session()) await query.edit_message_text( "🎌 *ربات حرفه‌ای ترجمه مانگا*\n\n" "با Gemini 2\\.0 Flash Vision\n\n" "یکی از گزینه‌های زیر رو انتخاب کن 👇", parse_mode=ParseMode.MARKDOWN_V2, reply_markup=start_menu_keyboard(), ) return # ── DPI ──────────────────────────────────────────────────── if data.startswith("dpi:"): dpi = int(data.split(":")[1]) s = get_session(chat_id) s.dpi = dpi logger.info(f"[DPI] set to {dpi} for user={user.id}") await query.edit_message_text( f"✅ کیفیت رندر روی *{dpi} DPI* تنظیم شد\\.\n\nحالا یک PDF بفرست\\!", parse_mode=ParseMode.MARKDOWN_V2, ) return # ── Language → start translation ─────────────────────────── if data.startswith("lang:"): lang = data.split(":", 1)[1] s = sessions.get(chat_id) if not s or not s.pdf_bytes: await query.edit_message_text( "⚠️ ابتدا یک PDF بفرست\\.", parse_mode=ParseMode.MARKDOWN_V2, ) return s.lang = lang s.cancel = False logger.info(f"[LANG] user={user.id} lang='{lang}' pages={s.total_pages} dpi={s.dpi}") await query.edit_message_text( f"🚀 *شروع ترجمه\\!*\n\n" f"🌐 زبان: *{esc(lang)}*\n" f"📄 صفحات: *{s.total_pages}*\n" f"⚙️ کیفیت: *{s.dpi} DPI*\n\n" f"⏳ در حال آماده‌سازی\\.\\.\\.", parse_mode=ParseMode.MARKDOWN_V2, ) asyncio.create_task( run_translation(chat_id, ctx, s, lang) ) # ══════════════════════════════════════════════════════════════════ # 🔄 TRANSLATION LOOP — با گزارش لحظه‌ای # ══════════════════════════════════════════════════════════════════ async def run_translation( chat_id: int, ctx: ContextTypes.DEFAULT_TYPE, s: Session, lang: str, ) -> None: total = s.total_pages dpi = s.dpi errors = [] total_dlgs = 0 t0 = time.time() is_fa = "Persian" in lang or "Farsi" in lang logger.info(f"[TRANS] START — chat={chat_id} lang={lang} pages={total} dpi={dpi}") # ── نوار پیشرفت اولیه ────────────────────────────────────── bar, pct = progress_bar(0, total) prog_msg = await safe_send( ctx.bot, chat_id, f"📊 *پیشرفت ترجمه*\n\n" f"`{bar}` {pct}%\n" f"📄 صفحه ۰/{total} ⏱ —", parse_mode=ParseMode.MARKDOWN_V2, ) for i in range(total): # بررسی لغو if sessions.get(chat_id, s).cancel: logger.info(f"[TRANS] cancelled at page {i+1}") await safe_send(ctx.bot, chat_id, "🛑 *ترجمه لغو شد\\.*", parse_mode=ParseMode.MARKDOWN_V2) return page_num = i + 1 elapsed = time.time() - t0 # ── بروزرسانی نوار پیشرفت ───────────────────────────── bar, pct = progress_bar(i, total) et = eta_str(elapsed, max(i, 1), total) elapsed_s = f"{int(elapsed//60)}m{int(elapsed%60):02d}s" if elapsed >= 60 else f"{int(elapsed)}s" if prog_msg: await safe_edit( prog_msg, f"📊 *پیشرفت ترجمه*\n\n" f"`{bar}` {pct}%\n" f"📄 صفحه {i}/{total} \\| ⏱ گذشته: {esc(elapsed_s)} \\| مانده: {esc(et)}", parse_mode=ParseMode.MARKDOWN_V2, ) # ── لاگ پیشرفت ──────────────────────────────────────── logger.info(f"[TRANS] page {page_num}/{total} — rendering at {dpi}DPI...") await ctx.bot.send_chat_action(chat_id, ChatAction.UPLOAD_PHOTO) try: # رندر تصویر t_render = time.time() jpeg = await asyncio.to_thread(render_page, s.pdf_bytes, i, dpi) logger.debug(f" render: {round(time.time()-t_render,2)}s size: {len(jpeg)//1024}KB") # ترجمه با Gemini t_gemini = time.time() items = await translate_page(jpeg, lang) logger.info(f" gemini: {round(time.time()-t_gemini,2)}s → {len(items)} items") total_dlgs += len(items) s.history.append({"page": page_num, "lang": lang, "items": items}) # ارسال تصویر صفحه caption = f"{'صفحه' if is_fa else 'Page'} {page_num}/{total}" await ctx.bot.send_photo(chat_id, photo=io.BytesIO(jpeg), caption=caption) # ارسال ترجمه text = format_page(items, page_num, total, lang) try: await ctx.bot.send_message(chat_id, text, parse_mode=ParseMode.MARKDOWN_V2) except Exception: # fallback plain text plain = re.sub(r"[\\*_`\[\]()~>#+=|{}.!]", "", text) await ctx.bot.send_message(chat_id, plain) except Exception as e: logger.error(f"[TRANS] page {page_num} FAILED: {e}") errors.append(page_num) await safe_send( ctx.bot, chat_id, f"⚠️ خطا در صفحه {page_num} — رد شد", ) await asyncio.sleep(1.5) # rate limit buffer # ── پایان: پاک کردن نوار پیشرفت ────────────────────────── if prog_msg: try: await prog_msg.delete() except Exception: pass elapsed_total = time.time() - t0 elapsed_fmt = f"{int(elapsed_total//60)}m {int(elapsed_total%60):02d}s" ok_pages = total - len(errors) logger.info( f"[TRANS] DONE — {ok_pages}/{total} pages translated | " f"{total_dlgs} items | {elapsed_fmt} | errors={errors}" ) # ── پیام خلاصه ──────────────────────────────────────────── summary = ( f"✅ *ترجمه کامل شد\\!*\n\n" f"📄 صفحات ترجمه شده: *{ok_pages}/{total}*\n" f"🌐 زبان: *{esc(lang)}*\n" f"⏱ زمان کل: *{esc(elapsed_fmt)}*\n" f"💬 مجموع دیالوگ‌ها: *{total_dlgs}*\n" ) if errors: summary += f"\n⚠️ خطا در صفحات: {esc(str(errors))}\n" summary += "\n📥 *فایل کامل ترجمه:*" await safe_send(ctx.bot, chat_id, summary, parse_mode=ParseMode.MARKDOWN_V2) # ── خروجی TXT ───────────────────────────────────────────── if s.history: try: txt = build_txt(s.history, lang, s.pdf_name) filename = f"translation_{s.pdf_name.replace('.pdf','')}.txt" await ctx.bot.send_document( chat_id, document=InputFile(io.BytesIO(txt.encode("utf-8")), filename=filename), caption="📄 فایل کامل ترجمه", ) logger.info(f"[TRANS] TXT exported: {filename}") except Exception as e: logger.warning(f"[TRANS] TXT export failed: {e}") # ══════════════════════════════════════════════════════════════════ # 🚀 MAIN # ══════════════════════════════════════════════════════════════════ def main(): logger.info("=" * 60) logger.info("🎌 Manga Translator Bot — starting up") logger.info(f"📋 Log file: {LOG_FILE}") logger.info("=" * 60) app = Application.builder().token(TELEGRAM_TOKEN).build() app.add_handler(CommandHandler("start", cmd_start)) app.add_handler(CommandHandler("help", cmd_help)) app.add_handler(CommandHandler("cancel", cmd_cancel)) app.add_handler(CommandHandler("history", cmd_history)) app.add_handler(MessageHandler(filters.Document.PDF, handle_pdf)) app.add_handler(CallbackQueryHandler(handle_callback)) async def post_init(application: Application): await application.bot.set_my_commands([ BotCommand("start", "🏠 منوی اصلی"), BotCommand("help", "📖 راهنما"), BotCommand("cancel", "🛑 لغو ترجمه"), BotCommand("history", "📚 تاریخچه"), ]) me = await application.bot.get_me() logger.info(f"✅ Bot ready: @{me.username} (id={me.id})") app.post_init = post_init app.run_polling(drop_pending_updates=True) if __name__ == "__main__": main()