mangayekire / bot.py
barnamemaker's picture
Upload bot.py
00d33b6 verified
Raw
History Blame Contribute Delete
34.5 kB
"""
🎌 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()