diff --git a/.env.example b/.env.example index d20fe19..bec13e5 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,10 @@ BOT_TOKEN= # Прокси для Telegram API. Пусто — без прокси. # В Docker на Linux: http://host.docker.internal:20171 TELEGRAM_PROXY= +# 0 — повторять до успеха; иначе число попыток +TELEGRAM_RETRY_ATTEMPTS=0 +TELEGRAM_RETRY_MAX_DELAY=30 +TELEGRAM_HEALTH_INTERVAL=60 # ComfyUI API COMFY_BASE_URL=http://192.168.31.113:8188 diff --git a/README.md b/README.md index 6b5dfc0..e771a24 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,19 @@ BLOCKED_PUBLIC_FIGURES= `ADMIN_USER_IDS` используется только для первичной инициализации админов; после первого запуска админы хранятся в БД и управляются через веб. +## Надёжность Telegram + +```dotenv +TELEGRAM_RETRY_ATTEMPTS=0 +TELEGRAM_RETRY_MAX_DELAY=30 +TELEGRAM_HEALTH_INTERVAL=60 +``` + +- `TELEGRAM_RETRY_ATTEMPTS=0` — повторять операции до успеха. +- Backoff растёт до `TELEGRAM_RETRY_MAX_DELAY`. +- Критичные отправки (результат, счёт, подтверждение) повторяются бесконечно; обновления статуса — ограниченно, чтобы не плодить зависшие задачи. +- Раз в `TELEGRAM_HEALTH_INTERVAL` выполняется `getMe`; при сбое проверка повторяется с backoff, пока Telegram не ответит. + ## Подтверждение возраста Перед использованием бота нужно подтвердить 18+: diff --git a/bot.py b/bot.py index 4ec02fe..9862484 100644 --- a/bot.py +++ b/bot.py @@ -66,6 +66,8 @@ class MpakoBot: self._status_messages: dict[int, tuple[int, int]] = {} self._last_edit: dict[int, tuple[float, int]] = {} self._daily_task: asyncio.Task[None] | None = None + self._health_task: asyncio.Task[None] | None = None + self._application: Application | None = None def run(self) -> None: builder = ( @@ -84,6 +86,7 @@ class MpakoBot: self._settings.telegram_proxy ) application = builder.build() + self._application = application application.add_handler(CommandHandler("start", self._start)) application.add_handler(CommandHandler("help", self._help)) application.add_handler(CommandHandler("status", self._status)) @@ -126,6 +129,9 @@ class MpakoBot: self._daily_task = asyncio.create_task( self._daily_credits_loop(), name="daily-credits" ) + self._health_task = asyncio.create_task( + self._telegram_health_loop(), name="telegram-health" + ) enhancer = ( PromptEnhancer(self._comfy, self._settings) if self._settings.prompt_enhance_enabled @@ -143,6 +149,10 @@ class MpakoBot: logger.info("Бот запущен, ComfyUI: %s", self._settings.comfy_base_url) async def _post_shutdown(self, application: Application) -> None: + if self._health_task is not None: + self._health_task.cancel() + await asyncio.gather(self._health_task, return_exceptions=True) + self._health_task = None if self._daily_task is not None: self._daily_task.cancel() await asyncio.gather(self._daily_task, return_exceptions=True) @@ -167,6 +177,28 @@ class MpakoBot: except Exception: logger.exception("Ошибка ежедневного начисления генераций") + async def _telegram_health_loop(self) -> None: + interval = self._settings.telegram_health_interval + max_delay = self._settings.telegram_retry_max_delay + delay = min(1.0, max_delay) + while True: + await asyncio.sleep(interval) + while True: + try: + if self._bot is not None: + await self._bot.get_me() + break + except (TimedOut, NetworkError): + logger.warning( + "Telegram health check failed, retry in %.0f s", delay + ) + await asyncio.sleep(delay) + delay = min(max_delay, delay * 2) + except TelegramError: + logger.exception("Telegram health check error") + break + delay = min(1.0, max_delay) + def _start_text(self, balance: int | None = None) -> str: parts: list[str] = [] if balance is not None: @@ -226,11 +258,15 @@ class MpakoBot: await message.reply_text(AGE_DENIED) return if not row.age_confirmed: - await message.reply_text( - AGE_TEXT, reply_markup=self._age_keyboard() + await self._retry( + message.reply_text, + AGE_TEXT, + reply_markup=self._age_keyboard(), ) return - await message.reply_text(self._start_text(row.credits)) + await self._retry( + message.reply_text, self._start_text(row.credits) + ) @staticmethod def _age_keyboard() -> InlineKeyboardMarkup: @@ -267,7 +303,8 @@ class MpakoBot: except TelegramError: pass if row is not None: - await self._bot.send_message( + await self._retry( + self._bot.send_message, chat_id=user.id, text=self._start_text(row.credits), ) @@ -290,7 +327,9 @@ class MpakoBot: if message is None or user is None: return row = await self._db.ensure_user(user.id, user.username) - await message.reply_text(self._start_text(row.credits)) + await self._retry( + message.reply_text, self._start_text(row.credits) + ) async def _balance( self, update: Update, context: ContextTypes.DEFAULT_TYPE @@ -302,8 +341,9 @@ class MpakoBot: if message is None or user is None: return row = await self._db.ensure_user(user.id, user.username) - await message.reply_text( - f"Баланс: {row.credits} {self._credits_word(row.credits)}." + await self._retry( + message.reply_text, + f"Баланс: {row.credits} {self._credits_word(row.credits)}.", ) async def _invite( @@ -331,7 +371,8 @@ class MpakoBot: ] ] ) - await message.reply_text( + await self._retry( + message.reply_text, "Пригласи друга:\n" f"{link}\n\n" "Когда друг впервые оплатит генерацию, ты получишь +3 генерации.", @@ -708,9 +749,16 @@ class MpakoBot: input_path = user_dir / f"{uuid.uuid4().hex}_input{suffix}" try: user_dir.mkdir(parents=True, exist_ok=True) - await message.chat.send_action(ChatAction.TYPING) - file = await attachment.get_file() - await file.download_to_drive(custom_path=input_path) + await self._retry( + message.chat.send_action, ChatAction.TYPING, attempts=3 + ) + + async def download() -> None: + input_path.unlink(missing_ok=True) + file = await attachment.get_file() + await file.download_to_drive(custom_path=input_path) + + await self._retry(download, attempts=5) except (TelegramError, OSError): logger.exception("Не удалось скачать вложение пользователя %s", user_id) input_path.unlink(missing_ok=True) @@ -995,22 +1043,37 @@ class MpakoBot: event.error or "Не удалось сгенерировать изображение.", ) - async def _retry(self, func, *args, attempts: int = 3, **kwargs): - delay = 1.0 - for attempt in range(1, attempts + 1): + async def _retry( + self, + func, + *args, + attempts: int | None = None, + **kwargs, + ): + max_attempts = ( + self._settings.telegram_retry_attempts + if attempts is None + else attempts + ) + infinite = max_attempts <= 0 + max_delay = self._settings.telegram_retry_max_delay + delay = min(1.0, max_delay) + attempt = 0 + while True: + attempt += 1 try: return await func(*args, **kwargs) except (TimedOut, NetworkError): - if attempt >= attempts: + if not infinite and attempt >= max_attempts: raise logger.warning( - "Telegram timeout (%s/%s), повтор через %.0f с", + "Telegram timeout (attempt %s%s), retry in %.1f s", attempt, - attempts, + "" if infinite else f"/{max_attempts}", delay, ) await asyncio.sleep(delay) - delay *= 2 + delay = min(max_delay, delay * 2) async def _edit_status( self, @@ -1042,6 +1105,7 @@ class MpakoBot: chat_id=chat_id, message_id=message_id, text=text, + attempts=3, ) except BadRequest as exc: if "not modified" not in str(exc).lower(): @@ -1083,7 +1147,6 @@ class MpakoBot: caption=caption, read_timeout=120, write_timeout=120, - attempts=2, ) except TelegramError: logger.exception("Не удалось отправить фото по запросу %s", request.id) @@ -1095,7 +1158,6 @@ class MpakoBot: caption=caption, read_timeout=120, write_timeout=120, - attempts=2, ) except TelegramError: await self._finish_failed( diff --git a/checklist.md b/checklist.md index 035325a..c5d84be 100644 --- a/checklist.md +++ b/checklist.md @@ -6,9 +6,9 @@ ## Текущий статус -- Реализованы блоки 2–7, базовое хранилище (9), файлы Docker (10), тесты (11), README (12), веб-история (13), улучшение промптов (14), модерация (15), оплата Stars (16), баланс/рефералы (17), управление пользователями (18) и возрастной gate (19). +- Реализованы блоки 2–7, базовое хранилище (9), файлы Docker (10), тесты (11), README (12), веб-история (13), улучшение промптов (14), модерация (15), оплата Stars (16), баланс/рефералы (17), управление пользователями (18), возрастной gate (19) и надёжность Telegram (20). - Вживую проверены: T2I (Z-Image), I2I (Krea2), WebSocket-прогресс, улучшение промптов, Docker, CI и веб-история. -- 52 теста проходят: БД, workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, управление пользователями. +- 54 теста проходят: БД, workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, управление пользователями, retry. - Не проверено: реальная оплата Stars и реферальная выплата в Telegram, очередь из 3+ пользователей, падение ComfyUI, рестарт с `running`-задачей, детекция лица на реальном фото. ## 0. Допущения и решения по умолчанию @@ -371,6 +371,15 @@ ON requests(user_id) WHERE status IN ('queued', 'running'); **DoD:** несовершеннолетний пользователь не может пользоваться ботом; подтверждение сохраняется. +## 20. Надёжность Telegram + +- [x] `_retry` с настраиваемыми попытками, backoff и режимом «до успеха». +- [x] Health-check `getMe` с повторами. +- [x] Ретраи критичных отправок и загрузок файлов. +- [x] Тесты retry. + +**DoD:** при таймаутах прокси операции повторяются, health-check фиксирует восстановление связи. + ## Порядок работ 1. Блок 1 (подготовка) — без него нельзя валидировать клиент и workflow. diff --git a/config.py b/config.py index 46d21cd..68be34e 100644 --- a/config.py +++ b/config.py @@ -73,6 +73,9 @@ def _csv(name: str) -> frozenset[str]: class Settings: bot_token: str telegram_proxy: str | None = None + telegram_retry_attempts: int = 0 + telegram_retry_max_delay: float = 30.0 + telegram_health_interval: float = 60.0 comfy_base_url: str = "http://192.168.31.113:8188" workflow_t2i: Path = PROJECT_ROOT / "workflows" / "txt2img.json" workflow_i2i: Path = PROJECT_ROOT / "workflows" / "img2img.json" @@ -129,6 +132,13 @@ class Settings: settings = cls( bot_token=token, telegram_proxy=proxy, + telegram_retry_attempts=max(0, _int("TELEGRAM_RETRY_ATTEMPTS", 0)), + telegram_retry_max_delay=max( + 1.0, _float("TELEGRAM_RETRY_MAX_DELAY", 30.0) + ), + telegram_health_interval=max( + 10.0, _float("TELEGRAM_HEALTH_INTERVAL", 60.0) + ), comfy_base_url=base_url.rstrip("/"), workflow_t2i=_path("WORKFLOW_T2I", "workflows/txt2img.json"), workflow_i2i=_path("WORKFLOW_I2I", "workflows/img2img.json"), diff --git a/tests/test_retry.py b/tests/test_retry.py new file mode 100644 index 0000000..39c16c7 --- /dev/null +++ b/tests/test_retry.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import pytest +from telegram.error import TimedOut + +from bot import MpakoBot +from config import Settings + + +def make_settings() -> Settings: + return Settings( + bot_token="", + telegram_retry_attempts=3, + telegram_retry_max_delay=0.01, + telegram_health_interval=3600, + ) + + +async def test_retry_succeeds_after_timeouts() -> None: + bot = MpakoBot(make_settings()) + calls = 0 + + async def flaky() -> str: + nonlocal calls + calls += 1 + if calls < 3: + raise TimedOut("proxy timeout") + return "ok" + + assert await bot._retry(flaky) == "ok" + assert calls == 3 + + +async def test_retry_raises_after_limit() -> None: + bot = MpakoBot(make_settings()) + calls = 0 + + async def broken() -> str: + nonlocal calls + calls += 1 + raise TimedOut("proxy timeout") + + with pytest.raises(TimedOut): + await bot._retry(broken, attempts=2) + assert calls == 2