Ретраи Telegram
Build and push Docker image / docker (push) Successful in 11s
Details
Build and push Docker image / docker (push) Successful in 11s
Details
This commit is contained in:
parent
927cf927f5
commit
c633ab834a
|
|
@ -4,6 +4,10 @@ BOT_TOKEN=
|
||||||
# Прокси для Telegram API. Пусто — без прокси.
|
# Прокси для Telegram API. Пусто — без прокси.
|
||||||
# В Docker на Linux: http://host.docker.internal:20171
|
# В Docker на Linux: http://host.docker.internal:20171
|
||||||
TELEGRAM_PROXY=
|
TELEGRAM_PROXY=
|
||||||
|
# 0 — повторять до успеха; иначе число попыток
|
||||||
|
TELEGRAM_RETRY_ATTEMPTS=0
|
||||||
|
TELEGRAM_RETRY_MAX_DELAY=30
|
||||||
|
TELEGRAM_HEALTH_INTERVAL=60
|
||||||
|
|
||||||
# ComfyUI API
|
# ComfyUI API
|
||||||
COMFY_BASE_URL=http://192.168.31.113:8188
|
COMFY_BASE_URL=http://192.168.31.113:8188
|
||||||
|
|
|
||||||
13
README.md
13
README.md
|
|
@ -139,6 +139,19 @@ BLOCKED_PUBLIC_FIGURES=
|
||||||
|
|
||||||
`ADMIN_USER_IDS` используется только для первичной инициализации админов; после первого запуска админы хранятся в БД и управляются через веб.
|
`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+:
|
Перед использованием бота нужно подтвердить 18+:
|
||||||
|
|
|
||||||
102
bot.py
102
bot.py
|
|
@ -66,6 +66,8 @@ class MpakoBot:
|
||||||
self._status_messages: dict[int, tuple[int, int]] = {}
|
self._status_messages: dict[int, tuple[int, int]] = {}
|
||||||
self._last_edit: dict[int, tuple[float, int]] = {}
|
self._last_edit: dict[int, tuple[float, int]] = {}
|
||||||
self._daily_task: asyncio.Task[None] | None = None
|
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:
|
def run(self) -> None:
|
||||||
builder = (
|
builder = (
|
||||||
|
|
@ -84,6 +86,7 @@ class MpakoBot:
|
||||||
self._settings.telegram_proxy
|
self._settings.telegram_proxy
|
||||||
)
|
)
|
||||||
application = builder.build()
|
application = builder.build()
|
||||||
|
self._application = application
|
||||||
application.add_handler(CommandHandler("start", self._start))
|
application.add_handler(CommandHandler("start", self._start))
|
||||||
application.add_handler(CommandHandler("help", self._help))
|
application.add_handler(CommandHandler("help", self._help))
|
||||||
application.add_handler(CommandHandler("status", self._status))
|
application.add_handler(CommandHandler("status", self._status))
|
||||||
|
|
@ -126,6 +129,9 @@ class MpakoBot:
|
||||||
self._daily_task = asyncio.create_task(
|
self._daily_task = asyncio.create_task(
|
||||||
self._daily_credits_loop(), name="daily-credits"
|
self._daily_credits_loop(), name="daily-credits"
|
||||||
)
|
)
|
||||||
|
self._health_task = asyncio.create_task(
|
||||||
|
self._telegram_health_loop(), name="telegram-health"
|
||||||
|
)
|
||||||
enhancer = (
|
enhancer = (
|
||||||
PromptEnhancer(self._comfy, self._settings)
|
PromptEnhancer(self._comfy, self._settings)
|
||||||
if self._settings.prompt_enhance_enabled
|
if self._settings.prompt_enhance_enabled
|
||||||
|
|
@ -143,6 +149,10 @@ class MpakoBot:
|
||||||
logger.info("Бот запущен, ComfyUI: %s", self._settings.comfy_base_url)
|
logger.info("Бот запущен, ComfyUI: %s", self._settings.comfy_base_url)
|
||||||
|
|
||||||
async def _post_shutdown(self, application: Application) -> None:
|
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:
|
if self._daily_task is not None:
|
||||||
self._daily_task.cancel()
|
self._daily_task.cancel()
|
||||||
await asyncio.gather(self._daily_task, return_exceptions=True)
|
await asyncio.gather(self._daily_task, return_exceptions=True)
|
||||||
|
|
@ -167,6 +177,28 @@ class MpakoBot:
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.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:
|
def _start_text(self, balance: int | None = None) -> str:
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
if balance is not None:
|
if balance is not None:
|
||||||
|
|
@ -226,11 +258,15 @@ class MpakoBot:
|
||||||
await message.reply_text(AGE_DENIED)
|
await message.reply_text(AGE_DENIED)
|
||||||
return
|
return
|
||||||
if not row.age_confirmed:
|
if not row.age_confirmed:
|
||||||
await message.reply_text(
|
await self._retry(
|
||||||
AGE_TEXT, reply_markup=self._age_keyboard()
|
message.reply_text,
|
||||||
|
AGE_TEXT,
|
||||||
|
reply_markup=self._age_keyboard(),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
await message.reply_text(self._start_text(row.credits))
|
await self._retry(
|
||||||
|
message.reply_text, self._start_text(row.credits)
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _age_keyboard() -> InlineKeyboardMarkup:
|
def _age_keyboard() -> InlineKeyboardMarkup:
|
||||||
|
|
@ -267,7 +303,8 @@ class MpakoBot:
|
||||||
except TelegramError:
|
except TelegramError:
|
||||||
pass
|
pass
|
||||||
if row is not None:
|
if row is not None:
|
||||||
await self._bot.send_message(
|
await self._retry(
|
||||||
|
self._bot.send_message,
|
||||||
chat_id=user.id,
|
chat_id=user.id,
|
||||||
text=self._start_text(row.credits),
|
text=self._start_text(row.credits),
|
||||||
)
|
)
|
||||||
|
|
@ -290,7 +327,9 @@ class MpakoBot:
|
||||||
if message is None or user is None:
|
if message is None or user is None:
|
||||||
return
|
return
|
||||||
row = await self._db.ensure_user(user.id, user.username)
|
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(
|
async def _balance(
|
||||||
self, update: Update, context: ContextTypes.DEFAULT_TYPE
|
self, update: Update, context: ContextTypes.DEFAULT_TYPE
|
||||||
|
|
@ -302,8 +341,9 @@ class MpakoBot:
|
||||||
if message is None or user is None:
|
if message is None or user is None:
|
||||||
return
|
return
|
||||||
row = await self._db.ensure_user(user.id, user.username)
|
row = await self._db.ensure_user(user.id, user.username)
|
||||||
await message.reply_text(
|
await self._retry(
|
||||||
f"Баланс: {row.credits} {self._credits_word(row.credits)}."
|
message.reply_text,
|
||||||
|
f"Баланс: {row.credits} {self._credits_word(row.credits)}.",
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _invite(
|
async def _invite(
|
||||||
|
|
@ -331,7 +371,8 @@ class MpakoBot:
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
await message.reply_text(
|
await self._retry(
|
||||||
|
message.reply_text,
|
||||||
"Пригласи друга:\n"
|
"Пригласи друга:\n"
|
||||||
f"{link}\n\n"
|
f"{link}\n\n"
|
||||||
"Когда друг впервые оплатит генерацию, ты получишь +3 генерации.",
|
"Когда друг впервые оплатит генерацию, ты получишь +3 генерации.",
|
||||||
|
|
@ -708,9 +749,16 @@ class MpakoBot:
|
||||||
input_path = user_dir / f"{uuid.uuid4().hex}_input{suffix}"
|
input_path = user_dir / f"{uuid.uuid4().hex}_input{suffix}"
|
||||||
try:
|
try:
|
||||||
user_dir.mkdir(parents=True, exist_ok=True)
|
user_dir.mkdir(parents=True, exist_ok=True)
|
||||||
await message.chat.send_action(ChatAction.TYPING)
|
await self._retry(
|
||||||
file = await attachment.get_file()
|
message.chat.send_action, ChatAction.TYPING, attempts=3
|
||||||
await file.download_to_drive(custom_path=input_path)
|
)
|
||||||
|
|
||||||
|
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):
|
except (TelegramError, OSError):
|
||||||
logger.exception("Не удалось скачать вложение пользователя %s", user_id)
|
logger.exception("Не удалось скачать вложение пользователя %s", user_id)
|
||||||
input_path.unlink(missing_ok=True)
|
input_path.unlink(missing_ok=True)
|
||||||
|
|
@ -995,22 +1043,37 @@ class MpakoBot:
|
||||||
event.error or "Не удалось сгенерировать изображение.",
|
event.error or "Не удалось сгенерировать изображение.",
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _retry(self, func, *args, attempts: int = 3, **kwargs):
|
async def _retry(
|
||||||
delay = 1.0
|
self,
|
||||||
for attempt in range(1, attempts + 1):
|
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:
|
try:
|
||||||
return await func(*args, **kwargs)
|
return await func(*args, **kwargs)
|
||||||
except (TimedOut, NetworkError):
|
except (TimedOut, NetworkError):
|
||||||
if attempt >= attempts:
|
if not infinite and attempt >= max_attempts:
|
||||||
raise
|
raise
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Telegram timeout (%s/%s), повтор через %.0f с",
|
"Telegram timeout (attempt %s%s), retry in %.1f s",
|
||||||
attempt,
|
attempt,
|
||||||
attempts,
|
"" if infinite else f"/{max_attempts}",
|
||||||
delay,
|
delay,
|
||||||
)
|
)
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
delay *= 2
|
delay = min(max_delay, delay * 2)
|
||||||
|
|
||||||
async def _edit_status(
|
async def _edit_status(
|
||||||
self,
|
self,
|
||||||
|
|
@ -1042,6 +1105,7 @@ class MpakoBot:
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
message_id=message_id,
|
message_id=message_id,
|
||||||
text=text,
|
text=text,
|
||||||
|
attempts=3,
|
||||||
)
|
)
|
||||||
except BadRequest as exc:
|
except BadRequest as exc:
|
||||||
if "not modified" not in str(exc).lower():
|
if "not modified" not in str(exc).lower():
|
||||||
|
|
@ -1083,7 +1147,6 @@ class MpakoBot:
|
||||||
caption=caption,
|
caption=caption,
|
||||||
read_timeout=120,
|
read_timeout=120,
|
||||||
write_timeout=120,
|
write_timeout=120,
|
||||||
attempts=2,
|
|
||||||
)
|
)
|
||||||
except TelegramError:
|
except TelegramError:
|
||||||
logger.exception("Не удалось отправить фото по запросу %s", request.id)
|
logger.exception("Не удалось отправить фото по запросу %s", request.id)
|
||||||
|
|
@ -1095,7 +1158,6 @@ class MpakoBot:
|
||||||
caption=caption,
|
caption=caption,
|
||||||
read_timeout=120,
|
read_timeout=120,
|
||||||
write_timeout=120,
|
write_timeout=120,
|
||||||
attempts=2,
|
|
||||||
)
|
)
|
||||||
except TelegramError:
|
except TelegramError:
|
||||||
await self._finish_failed(
|
await self._finish_failed(
|
||||||
|
|
|
||||||
13
checklist.md
13
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 и веб-история.
|
- Вживую проверены: T2I (Z-Image), I2I (Krea2), WebSocket-прогресс, улучшение промптов, Docker, CI и веб-история.
|
||||||
- 52 теста проходят: БД, workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, управление пользователями.
|
- 54 теста проходят: БД, workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, управление пользователями, retry.
|
||||||
- Не проверено: реальная оплата Stars и реферальная выплата в Telegram, очередь из 3+ пользователей, падение ComfyUI, рестарт с `running`-задачей, детекция лица на реальном фото.
|
- Не проверено: реальная оплата Stars и реферальная выплата в Telegram, очередь из 3+ пользователей, падение ComfyUI, рестарт с `running`-задачей, детекция лица на реальном фото.
|
||||||
|
|
||||||
## 0. Допущения и решения по умолчанию
|
## 0. Допущения и решения по умолчанию
|
||||||
|
|
@ -371,6 +371,15 @@ ON requests(user_id) WHERE status IN ('queued', 'running');
|
||||||
|
|
||||||
**DoD:** несовершеннолетний пользователь не может пользоваться ботом; подтверждение сохраняется.
|
**DoD:** несовершеннолетний пользователь не может пользоваться ботом; подтверждение сохраняется.
|
||||||
|
|
||||||
|
## 20. Надёжность Telegram
|
||||||
|
|
||||||
|
- [x] `_retry` с настраиваемыми попытками, backoff и режимом «до успеха».
|
||||||
|
- [x] Health-check `getMe` с повторами.
|
||||||
|
- [x] Ретраи критичных отправок и загрузок файлов.
|
||||||
|
- [x] Тесты retry.
|
||||||
|
|
||||||
|
**DoD:** при таймаутах прокси операции повторяются, health-check фиксирует восстановление связи.
|
||||||
|
|
||||||
## Порядок работ
|
## Порядок работ
|
||||||
|
|
||||||
1. Блок 1 (подготовка) — без него нельзя валидировать клиент и workflow.
|
1. Блок 1 (подготовка) — без него нельзя валидировать клиент и workflow.
|
||||||
|
|
|
||||||
10
config.py
10
config.py
|
|
@ -73,6 +73,9 @@ def _csv(name: str) -> frozenset[str]:
|
||||||
class Settings:
|
class Settings:
|
||||||
bot_token: str
|
bot_token: str
|
||||||
telegram_proxy: str | None = None
|
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"
|
comfy_base_url: str = "http://192.168.31.113:8188"
|
||||||
workflow_t2i: Path = PROJECT_ROOT / "workflows" / "txt2img.json"
|
workflow_t2i: Path = PROJECT_ROOT / "workflows" / "txt2img.json"
|
||||||
workflow_i2i: Path = PROJECT_ROOT / "workflows" / "img2img.json"
|
workflow_i2i: Path = PROJECT_ROOT / "workflows" / "img2img.json"
|
||||||
|
|
@ -129,6 +132,13 @@ class Settings:
|
||||||
settings = cls(
|
settings = cls(
|
||||||
bot_token=token,
|
bot_token=token,
|
||||||
telegram_proxy=proxy,
|
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("/"),
|
comfy_base_url=base_url.rstrip("/"),
|
||||||
workflow_t2i=_path("WORKFLOW_T2I", "workflows/txt2img.json"),
|
workflow_t2i=_path("WORKFLOW_T2I", "workflows/txt2img.json"),
|
||||||
workflow_i2i=_path("WORKFLOW_I2I", "workflows/img2img.json"),
|
workflow_i2i=_path("WORKFLOW_I2I", "workflows/img2img.json"),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue