Ретраи Telegram
Build and push Docker image / docker (push) Successful in 11s Details

This commit is contained in:
MpakobecEx 2026-10-18 23:37:30 +05:00
parent 927cf927f5
commit c633ab834a
6 changed files with 165 additions and 22 deletions

View File

@ -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

View File

@ -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+:

102
bot.py
View File

@ -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(

View File

@ -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.

View File

@ -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"),

45
tests/test_retry.py Normal file
View File

@ -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