Spaces:
Sleeping
Sleeping
| """ | |
| 🎌 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 | |
| # ══════════════════════════════════════════════════════════════════ | |
| 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" | |
| # ══════════════════════════════════════════════════════════════════ | |
| # ══════════════════════════════════════════════════════════════════ | |
| 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() | |