Spaces:
Running
Running
SilverElixir
router: провайдер Munito под флагом MUNITO_ENABLED — весь текст там, прежние цепочки резервом по второму флагу
7598b3a Download lumen_commands.py from SilverElixir/Lumen: direct link, hf CLI and curl.
- Browser
- Download file 62.8 kB
-
https://huggingface.co/spaces/SilverElixir/Lumen/resolve/main/lumen_commands.py
- Command line
-
hf download hf://spaces/SilverElixir/Lumen/lumen_commands.py
-
curl -L -o lumen_commands.py https://huggingface.co/spaces/SilverElixir/Lumen/resolve/main/lumen_commands.py
62.8 kB
| """ | |
| lumen_commands.py — команды бота, TTS/Draw-пайплайны и кнопки-уточнения. Связи с bot.py — только через отложенный `import bot`; bot.py реэкспортирует имена и регистрирует хендлеры. | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import contextlib | |
| import html | |
| import logging | |
| import os | |
| import re | |
| import secrets | |
| import subprocess | |
| import tempfile | |
| import time | |
| from types import SimpleNamespace | |
| from typing import Any | |
| from aiogram.enums import ChatType, ParseMode | |
| from aiogram.types import ( | |
| BufferedInputFile, | |
| CallbackQuery, | |
| FSInputFile, | |
| InlineKeyboardButton, | |
| InlineKeyboardMarkup, | |
| Message, | |
| ) | |
| from lumen_images import ( | |
| POLLINATIONS_IMAGE_MODELS, | |
| _pick_image_model, | |
| ) | |
| from lumen_lang import SUPPORTED_LANGS, LANG_NAMES, normalize_lang, pick_texts, t as _lang_t | |
| from lumen_limits import ( | |
| PICK_TTL_SEC, | |
| _purge_expired_picks, | |
| _enforce_pending_picks_cap, | |
| ) | |
| from lumen_tiktok import _communicate_process | |
| from lumen_tts import ( | |
| pcm_to_wav, | |
| _fish_audio_tts_bytes as _lumen_fish_audio_tts_bytes, | |
| _gemini_tts_bytes as _lumen_gemini_tts_bytes, | |
| ) | |
| log = logging.getLogger("bot") | |
| async def cmd_start(message: Message) -> None: | |
| import bot | |
| if getattr(message, "from_user", None) is not None and bot._is_banned(message.from_user.id): | |
| return | |
| # Лимит и здесь: хендлер зарегистрирован раньше общего catch-all, поэтому | |
| # сообщение до _reject_rate_limited_message не доходило — спамер получал | |
| # бесконечные ответы в группе, расходуя прокси-трафик (аудит 26.09.2026). | |
| if await bot._reject_rate_limited_message(message): | |
| return | |
| text = bot._t(message.chat.id, "start_text") | |
| if message.chat.type != ChatType.PRIVATE: | |
| # В группах контекст честно виден: фон чата (до 100 сообщений) уходит провайдерам вместе с вопросом. | |
| text += "\n\n" + bot._t(message.chat.id, "group_history_notice") | |
| await bot._tg_call( | |
| message.reply, | |
| text, | |
| parse_mode=ParseMode.HTML, | |
| ) | |
| async def inline_draw(message: Message, prompt: str) -> None: | |
| import bot | |
| # Дневной лимит пользователя: атомарный резерв ДО первого await, как в озвучке. | |
| # Проверка в начале, а списание после генерации, позволяли пяти параллельным | |
| # /draw пробить лимит (аудит 01.11.2026). | |
| uid = bot._user_key_for_message(message) | |
| if not bot._reserve_user_daily(uid, total=1): | |
| _draw_entry = bot._user_daily_peek(uid) | |
| hours, mins = bot._user_daily_reset_in() | |
| bot._record_stats_event("daily_limit_denials") | |
| await bot._safe_reply(message, bot._t( | |
| message.chat.id, "user_daily_total", | |
| used=(_draw_entry or {}).get("total", 0), limit=bot._user_daily_limit(uid, "total", _draw_entry), | |
| hours=hours, mins=mins, | |
| )) | |
| return | |
| from lumen_status import StatusMessage | |
| # Инициализируем ДО try: сбой самого пузыря иначе уводил бы обработчик ошибки | |
| # на несвязанное имя (аудит 01.11.2026). | |
| status_obj = None | |
| status = None | |
| try: | |
| status_obj = await StatusMessage.start_action( | |
| message, bot._chat_lang(message.chat.id), bot._t(message.chat.id, "status_generating_image"), | |
| ) | |
| status = status_obj.message if status_obj is not None else None | |
| session = await bot._get_http_session() | |
| # Модель — автовыбором по промпту (/imgmodel убран 19.08.2026), без состояния чата. | |
| primary_model = _pick_image_model(prompt) | |
| # Фолбэк-цепочка: подобранная модель первой, дальше остальные по порядку. | |
| all_model_ids = list(POLLINATIONS_IMAGE_MODELS.keys()) | |
| fallback_chain = [primary_model] + [m for m in all_model_ids if m != primary_model] | |
| image_bytes = None | |
| last_error = None | |
| budget_exceeded = False | |
| rate_limited = False | |
| deadline = time.monotonic() + bot.DRAW_TOTAL_BUDGET_SEC | |
| for attempt_model in fallback_chain: | |
| if time.monotonic() > deadline: | |
| budget_exceeded = True | |
| log.warning( | |
| '[draw] Overall time budget (%.0fs) exhausted before trying %s — stopping the fallback chain instead of trying the remaining models.', | |
| bot.DRAW_TOTAL_BUDGET_SEC, attempt_model, | |
| ) | |
| break | |
| try: | |
| # Статус без названий моделей (см. ИДЕНТИЧНОСТЬ в system_prompt.py); на первой попытке статус и так стоит. | |
| if attempt_model != primary_model: | |
| if status_obj is not None: | |
| await status_obj.set_action_key("status_taking_longer") | |
| else: | |
| await bot._edit_message_quietly(status, bot._t(message.chat.id, "status_taking_longer")) | |
| # Таймаут попытки — не больше остатка бюджета и не больше 40 с: | |
| # одна зависшая модель не должна съедать все 120 с и лишать | |
| # шанса резервные (аудит 29.10.2026). | |
| attempt_timeout = max(1.0, min(40.0, deadline - time.monotonic())) | |
| image_bytes = await bot._pollinations_text_to_image(session, attempt_model, prompt, timeout_sec=attempt_timeout) | |
| break | |
| except Exception as exc: | |
| last_error = exc | |
| txt = bot._error_text(exc).lower() | |
| # 429 — перегрузка ВСЕГО сервиса (прод 17.09.2026: все 5 моделей подряд), остаток цепочки не гоняем. | |
| if "429" in txt or "rate" in txt or "too many" in txt: | |
| log.warning("[draw] Service rate-limited (429) on model %s — stopping the fallback chain.", attempt_model) | |
| rate_limited = True | |
| break | |
| # Пробуем следующую только при сетевых/серверных ошибках | |
| if any(kw in txt for kw in ("cannot connect", "ssl:", "no address", "503", "502", "timeout", "host")): | |
| log.warning("[draw] Model %s failed (%s), trying next fallback", attempt_model, type(exc).__name__) | |
| continue | |
| # При других ошибках (400, 404 и т.д.) тоже пробуем следующую | |
| log.warning("[draw] Model %s error: %s, trying next", attempt_model, exc) | |
| continue | |
| if image_bytes: | |
| if status_obj is not None: | |
| await status_obj.delete() | |
| else: | |
| await bot._delete_message_quietly(status) | |
| # Через _tg_call: breaker-гейт, таймаут и RetryAfter вместо прямого send_photo. | |
| # _tg_call отдаёт None вместо исключения — внешний except ждёт исключение, | |
| # поэтому отсутствие результата превращаем в ошибку сервиса здесь. | |
| sent = await bot._tg_call( | |
| bot.bot.send_photo, | |
| chat_id=message.chat.id, | |
| photo=BufferedInputFile(image_bytes, filename="generated.jpg"), | |
| reply_to_message_id=message.message_id, | |
| call_timeout=bot.TELEGRAM_MEDIA_TIMEOUT, | |
| ) | |
| if sent is None: | |
| raise RuntimeError("Telegram send_photo failed: connection timeout or proxy unavailable") | |
| # Суточный счётчик уже списан резервом выше; в /stats картинка — и | |
| # полученное сообщение, и отправленный ответ (иначе «ответов» было | |
| # больше, чем «сообщений» — аудит 01.11.2026). | |
| bot._record_stats_event("messages_received") | |
| bot._record_stats_event("answers_sent") | |
| else: | |
| if rate_limited: | |
| raise RuntimeError("image generation service overloaded with requests") from last_error | |
| if budget_exceeded: | |
| raise RuntimeError("image generation total time budget exceeded") from last_error | |
| raise last_error or RuntimeError("all image generation models unavailable") | |
| except asyncio.CancelledError: | |
| if status_obj is not None: | |
| await status_obj.stop() | |
| bot._refund_user_daily(uid, total=1) | |
| raise | |
| except Exception as exc: | |
| log.exception("Pollinations image generation failed:") | |
| txt = bot._error_text(exc).strip() | |
| cid = message.chat.id | |
| if any(kw in txt.lower() for kw in ("cannot connect", "ssl:", "no address", "connection", "timeout", "host")): | |
| user_err = bot._t(cid, "draw_err_unavailable") | |
| elif "overloaded" in txt.lower(): | |
| user_err = bot._t(cid, "draw_err_overloaded") | |
| elif "all image generation" in txt.lower(): | |
| user_err = bot._t(cid, "draw_err_gone") | |
| elif "time budget" in txt.lower(): | |
| user_err = bot._t(cid, "draw_err_budget") | |
| else: | |
| # Сырой текст провайдера пользователю не показываем — generic-текст. | |
| user_err = bot._t(cid, "draw_err_generic") | |
| # Статус уже снесён выше (перед send_photo): если правка не прошла — дублируем реплаем, | |
| # иначе пользователь не увидит вообще ничего (найдено внешним аудитом). | |
| if status_obj is not None: | |
| await status_obj.stop() | |
| if not await bot._edit_message_quietly(status, user_err): | |
| await bot._safe_reply(message, user_err) | |
| # Картинки нет — дневной слот возвращаем (резерв списан заранее). | |
| bot._refund_user_daily(uid, total=1) | |
| async def cmd_draw(message: Message) -> None: | |
| import bot | |
| if getattr(message, "from_user", None) is not None and bot._is_banned(message.from_user.id): | |
| return | |
| prompt = message.text.partition(" ")[2].strip() if message.text else "" | |
| if not prompt: | |
| await bot._safe_reply(message, bot._t(message.chat.id, "draw_empty")) | |
| return | |
| if await bot._reject_rate_limited_message(message): | |
| return | |
| await bot.inline_draw(message, prompt) | |
| async def _fish_audio_tts_bytes(text: str) -> bytes | None: | |
| import bot | |
| if not bot.OPENROUTER_API_KEY: | |
| # Без ключа всё равно вернётся None: сессию не открываем (в тестах | |
| # она оставалась жить до выхода интерпретатора и печатала в stderr). | |
| return None | |
| session = await bot._get_http_session() | |
| return await _lumen_fish_audio_tts_bytes( | |
| session, text, | |
| api_key=bot.OPENROUTER_API_KEY, http_referer=bot.OPENROUTER_HTTP_REFERER, | |
| title=bot.OPENROUTER_TITLE, base_url=bot.OPENROUTER_BASE_URL, | |
| model_id=bot.FISH_AUDIO_TTS_MODEL, request_timeout_sec=bot.ROUTE_MODEL_TIMEOUT_SEC, | |
| ) | |
| async def _gemini_tts_bytes(text: str) -> tuple[bytes, str, str]: | |
| import bot | |
| def _is_rate_limit(e: Exception) -> bool: | |
| err_txt = bot._error_text(e).strip() or e.__class__.__name__ | |
| return bot._classify_model_error(bot._error_status(e, err_txt), err_txt) == "rate_limit" | |
| # TTS пишется в провайдер "gemini" — модели видны в /stats рядом с остальными. | |
| # Различаем суточную квоту и минутный всплеск так же, как текстовый маршрут: | |
| # иначе один 429 убирал бы TTS-модель из квоты до полуночи. | |
| def _is_daily_quota(e: Exception) -> bool: | |
| err_txt = bot._error_text(e).strip() or e.__class__.__name__ | |
| return bot._is_gemini_daily_quota(err_txt) | |
| return await _lumen_gemini_tts_bytes( | |
| bot.client, text, tts_models=bot.GEMINI_TTS_MODELS, | |
| is_rate_limit_error=_is_rate_limit, | |
| on_model_exhausted=lambda mname: bot._mark_quota_exhausted("gemini", mname), | |
| on_model_success=lambda mname: bot._record_quota_usage("gemini", mname), | |
| is_daily_quota_error=_is_daily_quota, | |
| on_model_rate_limited=lambda mname: bot._mark_rate_limited("gemini", mname), | |
| request_timeout_sec=bot.TTS_SYNTH_TIMEOUT_SEC, | |
| ) | |
| # Длинную озвучку бьём на части вместо отказа: кап частей — чтобы вставка | |
| # целой статьи не сожгла дневную квоту TTS и не спамила десятками голосовых. | |
| TTS_MAX_PARTS = 5 | |
| _SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?…\n])\s+") | |
| def _split_tts_chunks(text: str, limit: int) -> list[str]: | |
| """Режем текст на куски ≤ limit по границам предложений; одиночное | |
| предложение длиннее лимита — жёстко. Пустой вход — пустой список.""" | |
| text = (text or "").strip() | |
| if not text: | |
| return [] | |
| if len(text) <= limit: | |
| return [text] | |
| chunks: list[str] = [] | |
| cur = "" | |
| for piece in [p for p in _SENTENCE_SPLIT_RE.split(text) if p]: | |
| while len(piece) > limit: | |
| if cur: | |
| chunks.append(cur) | |
| cur = "" | |
| chunks.append(piece[:limit]) | |
| piece = piece[limit:] | |
| if not piece: | |
| continue | |
| cand = (cur + " " + piece).strip() | |
| if len(cand) <= limit: | |
| cur = cand | |
| else: | |
| if cur: | |
| chunks.append(cur) | |
| cur = piece | |
| if cur: | |
| chunks.append(cur) | |
| return chunks or [text[:limit]] | |
| async def _synthesize_tts_voice(text: str) -> tuple[bytes, str, int]: | |
| """Синтез + ffmpeg-конвертация одного куска в OGG/Opus. Тело прежнего | |
| inline_tts без смены логики — чанки идут тем же путём, что одиночный текст.""" | |
| import bot | |
| # Fish снят с free-каталога (17.09.2026) — пропускаем мёртвую попытку, ветка оставлена (см. флаг). | |
| fish_bytes = await bot._fish_audio_tts_bytes(text) if bot.FISH_AUDIO_ENABLED else None | |
| if fish_bytes is not None: | |
| pcm_bytes, mime_type, used_tts_model = fish_bytes, "audio/mp3", bot.FISH_AUDIO_TTS_MODEL | |
| bot._record_quota_usage("openrouter", bot.FISH_AUDIO_TTS_MODEL) | |
| else: | |
| pcm_bytes, mime_type, used_tts_model = await bot._gemini_tts_bytes(text) | |
| log.info('[tts] Synthesis received from %s, mime_type=%s, bytes=%d', used_tts_model, mime_type, len(pcm_bytes)) | |
| # определяем формат исходника | |
| if mime_type.startswith("audio/mp3") or mime_type.startswith("audio/mpeg") or pcm_bytes.startswith(b'ID3') or pcm_bytes.startswith(b'\xff\xfb'): | |
| src_ext = ".mp3" | |
| raw_audio = pcm_bytes | |
| elif pcm_bytes.startswith(b'RIFF') or "wav" in mime_type: | |
| src_ext = ".wav" | |
| raw_audio = pcm_bytes | |
| else: | |
| # сырой PCM сначала оборачиваем в WAV | |
| src_ext = ".wav" | |
| raw_audio = pcm_to_wav(pcm_bytes, sample_rate=24000) | |
| # send_voice без ffmpeg-конвертации показал бы 0:00. | |
| final_audio = raw_audio | |
| final_filename = "speech.ogg" | |
| voice_duration = 0 | |
| try: | |
| with tempfile.TemporaryDirectory() as tdir: | |
| src_path = os.path.join(tdir, f"tts_src{src_ext}") | |
| dst_path = os.path.join(tdir, "tts_out.ogg") | |
| with open(src_path, "wb") as fh: | |
| fh.write(raw_audio) | |
| proc = await asyncio.create_subprocess_exec( | |
| "ffmpeg", "-y", "-i", src_path, | |
| "-c:a", "libopus", "-b:a", "64k", "-vbr", "on", | |
| dst_path, | |
| stdout=asyncio.subprocess.DEVNULL, | |
| stderr=asyncio.subprocess.DEVNULL, | |
| ) | |
| await _communicate_process(proc, timeout=30) | |
| if os.path.exists(dst_path) and os.path.getsize(dst_path) > 0: | |
| with open(dst_path, "rb") as fh: | |
| final_audio = fh.read() | |
| # ffprobe — получаем длительность для Telegram (без неё показывает 0:00) | |
| try: | |
| probe = await asyncio.create_subprocess_exec( | |
| "ffprobe", "-v", "error", | |
| "-show_entries", "format=duration", | |
| "-of", "default=noprint_wrappers=1:nokey=1", | |
| dst_path, | |
| stdout=asyncio.subprocess.PIPE, | |
| stderr=asyncio.subprocess.DEVNULL, | |
| ) | |
| probe_out, _ = await _communicate_process(probe, timeout=10) | |
| raw_dur = probe_out.decode().strip() | |
| voice_duration = max(1, round(float(raw_dur))) if raw_dur else 0 | |
| except Exception as probe_exc: | |
| log.warning("[tts] ffprobe failed: %s", probe_exc) | |
| log.info("[tts] OGG/Opus: %d bytes, duration: %ds", len(final_audio), voice_duration) | |
| else: | |
| log.warning("[tts] ffmpeg OGG conversion failed, falling back to raw audio") | |
| final_filename = f"speech{src_ext}" | |
| except Exception as conv_exc: | |
| log.warning("[tts] ffmpeg conversion error: %s", conv_exc, exc_info=True) | |
| final_filename = f"speech{src_ext}" | |
| return final_audio, final_filename, voice_duration | |
| async def inline_tts(message: Message, text: str) -> None: | |
| import bot | |
| chunks = _split_tts_chunks(text, bot.TTS_MAX_CHARS) | |
| if not chunks: | |
| return | |
| if len(chunks) > TTS_MAX_PARTS: | |
| await bot._safe_reply( | |
| message, | |
| bot._t(message.chat.id, "tts_too_long", limit=bot.TTS_MAX_CHARS * TTS_MAX_PARTS, length=len(text)), | |
| ) | |
| return | |
| # Дневные лимиты пользователя: общий и отдельный на озвучку (он же тратит квоту Gemini TTS). | |
| # Проба без создания: отказ не заводит запись и не раздувает day_users. | |
| # Считаем сразу все чанки: каждый — отдельный синтез, иначе длинный текст | |
| # уводил бы счётчик за лимит. | |
| uid = bot._user_key_for_message(message) | |
| # Атомарный резерв ДО первого await: параллельные /tts раньше читали 0 | |
| # и обходили дневной лимит (аудит 28.10.2026). Резерв списывает сразу все | |
| # чанки, потому что каждый — отдельный синтез за квоту. | |
| if not bot._reserve_user_daily(uid, total=len(chunks), tts=len(chunks)): | |
| _tts_entry = bot._user_daily_peek(uid) | |
| try: | |
| _tts_over_total = int((_tts_entry or {}).get("total") or 0) + len(chunks) > bot._user_daily_limit(uid, "total", _tts_entry) | |
| _tts_over_tts = int((_tts_entry or {}).get("tts") or 0) + len(chunks) > bot._user_daily_limit(uid, "tts", _tts_entry) | |
| except (TypeError, ValueError): | |
| _tts_over_total = _tts_over_tts = False | |
| if bot._user_daily_total_exhausted(uid, _tts_entry) or _tts_over_total: | |
| hours, mins = bot._user_daily_reset_in() | |
| # Суточный счётчик /stats: отказ по лимиту, озвучки не будет. | |
| bot._record_stats_event("daily_limit_denials") | |
| await bot._safe_reply(message, bot._t( | |
| message.chat.id, "user_daily_total", | |
| used=(_tts_entry or {}).get("total", 0), limit=bot._user_daily_limit(uid, "total", _tts_entry), | |
| hours=hours, mins=mins, | |
| )) | |
| return | |
| if bot._user_daily_tts_exhausted(uid, _tts_entry) or _tts_over_tts: | |
| bot._record_stats_event("daily_limit_denials") | |
| await bot._safe_reply(message, bot._t( | |
| message.chat.id, "user_daily_tts", | |
| used=(_tts_entry or {}).get("tts", 0), limit=bot._user_daily_limit(uid, "tts", _tts_entry), | |
| )) | |
| return | |
| return | |
| from lumen_status import StatusMessage | |
| status_obj = await StatusMessage.start_action( | |
| message, bot._chat_lang(message.chat.id), bot._t(message.chat.id, "status_voicing"), | |
| ) | |
| status = status_obj.message if status_obj is not None else None | |
| # Общий дедлайн на все чанки: зависший синтез иначе держал per-chat lock без края. | |
| deadline = time.monotonic() + bot.TTS_TOTAL_BUDGET_SEC | |
| shortened = False | |
| try: | |
| # Весь синтез ДО отправки: упавший кусок — одна ошибка вместо рваного "пол-ответа + ошибка". | |
| voices = [] | |
| for chunk in chunks: | |
| remaining = deadline - time.monotonic() | |
| if remaining <= 0: | |
| shortened = True | |
| break | |
| try: | |
| voices.append(await asyncio.wait_for(_synthesize_tts_voice(chunk), timeout=remaining)) | |
| except asyncio.TimeoutError: | |
| shortened = True | |
| break | |
| if not voices: | |
| # Даже первый кусок не уложился — честная ошибка, а не пустота. | |
| raise RuntimeError("TTS total budget exceeded before the first chunk") | |
| except asyncio.CancelledError: | |
| if status_obj is not None: | |
| await status_obj.stop() | |
| raise | |
| except Exception as exc: | |
| log.exception("TTS synthesis failed:") | |
| # Сырой текст ошибки содержит ID моделей — показываем классифицированный текст. | |
| txt = bot._error_text(exc).strip() or exc.__class__.__name__ | |
| kind = bot._classify_model_error(bot._error_status(exc, txt), txt) | |
| cid = message.chat.id | |
| if kind == "rate_limit": | |
| user_err = bot._t(cid, "tts_err_exhausted") | |
| else: | |
| user_err = bot._t(cid, "tts_err_generic") | |
| # Статус уже снесён выше (перед send_voice): если правка не прошла — дублируем реплаем. | |
| if status_obj is not None: | |
| await status_obj.stop() | |
| if not await bot._edit_message_quietly(status, user_err): | |
| await bot._safe_reply(message, user_err) | |
| bot._refund_user_daily(uid, total=len(chunks), tts=len(chunks)) | |
| return | |
| if status_obj is not None: | |
| await status_obj.delete() | |
| else: | |
| await bot._delete_message_quietly(status) | |
| for i, (final_audio, final_filename, voice_duration) in enumerate(voices): | |
| # Через _tg_call: breaker-гейт, таймаут и RetryAfter вместо прямого send_voice. | |
| # None вместо исключения — наружу то же исключение, что раньше при прямом вызове. | |
| sent = await bot._tg_call( | |
| bot.bot.send_voice, | |
| chat_id=message.chat.id, | |
| voice=BufferedInputFile(final_audio, filename=final_filename), | |
| duration=voice_duration if voice_duration > 0 else None, | |
| reply_to_message_id=message.message_id if i == 0 else None, | |
| call_timeout=bot.TELEGRAM_MEDIA_TIMEOUT, | |
| ) | |
| if sent is None: | |
| # Неотправленные чанки возвращаем в лимит: резерв списал их заранее. | |
| bot._refund_user_daily(uid, total=len(chunks) - i, tts=len(chunks) - i) | |
| # Не оставляем пользователя без сообщения: статус уже удалён. | |
| await bot._safe_reply(message, bot._t(message.chat.id, "tts_err_generic")) | |
| return | |
| unsent = len(chunks) - len(voices) | |
| if unsent > 0: | |
| # Обрезка по общему дедлайну: за несозданные чанки не списываем. | |
| bot._refund_user_daily(uid, total=unsent, tts=unsent) | |
| # Оба счётчика пары: без messages_received «ответов» было больше, чем | |
| # «сообщений» (слэш-команды и триггеры выходят из ядра раньше — аудит 01.11.2026). | |
| bot._record_stats_event("messages_received") | |
| bot._record_stats_event("answers_sent") | |
| if shortened: | |
| # Обрезка по общему дедлайну — говорим прямо, что озвучено начало. | |
| await bot._safe_reply(message, bot._t(message.chat.id, "tts_shortened")) | |
| async def cmd_tts(message: Message) -> None: | |
| import bot | |
| if getattr(message, "from_user", None) is not None and bot._is_banned(message.from_user.id): | |
| return | |
| text = message.text.partition(" ")[2].strip() if message.text else "" | |
| if not text: | |
| await bot._safe_reply(message, bot._t(message.chat.id, "tts_empty")) | |
| return | |
| if await bot._reject_rate_limited_message(message): | |
| return | |
| await bot.inline_tts(message, text) | |
| async def cmd_reset(message: Message) -> None: | |
| """Сброс истории чата. В меню. Личка — всем, группа — админам/владельцу.""" | |
| import bot | |
| if getattr(message, "from_user", None) is not None and bot._is_banned(message.from_user.id): | |
| return | |
| # Слэш-команды регистрируются раньше catch-all и к общему лимиту не доходили | |
| # (тот же пропуск, что закрыли для /start — аудит 01.11.2026). | |
| if await bot._reject_rate_limited_message(message): | |
| return | |
| requester_id = message.from_user.id if message.from_user else None | |
| if not await bot._is_privileged_in_chat(message.chat.type, message.chat.id, requester_id): | |
| await bot._tg_call( | |
| message.reply, | |
| bot._t(message.chat.id, "reset_deny") | |
| ) | |
| return | |
| state = bot.get_state(message.chat.id) | |
| state["history"] = [] | |
| state["ctx"].clear() | |
| # Сброс должен забывать и ссылки на медиа/имена: иначе старые файлы | |
| # всплывали после /reset (аудит 28.10.2026). | |
| state.get("recent_media_ids", {}).clear() | |
| state.get("user_names", {}).clear() | |
| bot.mark_state_dirty(message.chat.id) | |
| await bot._tg_call( | |
| message.reply, | |
| bot._t(message.chat.id, "reset_done") | |
| ) | |
| # ── Язык системных сообщений (/lang) — ответы ИИ не трогает (см. RESPONSE LANGUAGE в system_prompt.py). Права как у /reset; кнопки без флагов/эмодзи, текущий — с ✓, меню по алфавиту кода. | |
| async def cmd_lang(message: Message) -> None: | |
| import bot | |
| if getattr(message, "from_user", None) is not None and bot._is_banned(message.from_user.id): | |
| return | |
| # Меню из 26 кнопок на каждое сообщение спамера = круг к Telegram + новая запись | |
| # чата (см. лимит в cmd_start, аудит 01.11.2026). | |
| if await bot._reject_rate_limited_message(message): | |
| return | |
| lang = bot._chat_lang(message.chat.id) | |
| rows = [] | |
| codes = list(SUPPORTED_LANGS) | |
| for i in range(0, len(codes), 2): | |
| row = [] | |
| for code in codes[i:i + 2]: | |
| mark = " ✓" if code == lang else "" | |
| row.append(InlineKeyboardButton( | |
| text=f"{LANG_NAMES[code]}{mark}", | |
| callback_data=f"lang:{code}", | |
| )) | |
| rows.append(row) | |
| await bot._tg_call( | |
| message.reply, | |
| bot._t(message.chat.id, "lang_title"), | |
| reply_markup=InlineKeyboardMarkup(inline_keyboard=rows), | |
| ) | |
| async def handle_lang_callback(query: CallbackQuery) -> None: | |
| """Кнопка языка ("lang:<код>", влезает в лимит 64 байт): права, сохранение, подтверждение на новом языке.""" | |
| import bot | |
| if getattr(query, "from_user", None) is not None and bot._is_banned(query.from_user.id): | |
| with contextlib.suppress(Exception): | |
| await query.answer() | |
| return | |
| data = query.data or "" | |
| if not data.startswith("lang:"): | |
| return | |
| parts = data.split(":") | |
| if len(parts) != 2: | |
| with contextlib.suppress(Exception): | |
| await query.answer() | |
| return | |
| code = normalize_lang(parts[1]) | |
| question_msg = query.message | |
| if question_msg is None or question_msg.chat is None: | |
| with contextlib.suppress(Exception): | |
| await query.answer() | |
| return | |
| chat = question_msg.chat | |
| requester_id = query.from_user.id if query.from_user else None | |
| if not await bot._is_privileged_in_chat(chat.type, chat.id, requester_id): | |
| with contextlib.suppress(Exception): | |
| await query.answer(bot._t(chat.id, "lang_deny"), show_alert=True) | |
| return | |
| state = bot.get_state(chat.id) | |
| state["lang"] = code | |
| bot.mark_state_dirty(chat.id) | |
| with contextlib.suppress(Exception): | |
| await query.answer() | |
| with contextlib.suppress(Exception): | |
| await bot._tg_call( | |
| question_msg.edit_text, | |
| _lang_t(code, "lang_done"), | |
| parse_mode=None, call_timeout=15.0, | |
| reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), | |
| ) | |
| # выгрузка логов (только для владельца) | |
| async def cmd_logs(message: Message) -> None: | |
| import bot | |
| is_owner = bot._is_owner(message.from_user.id if message.from_user else None) | |
| if not is_owner: | |
| await bot._tg_call(message.reply, bot._t(message.chat.id, "logs_deny")) | |
| return | |
| if message.chat.type != ChatType.PRIVATE: | |
| # Результат команды видят ВСЕ в чате — файл логов с ID моделей в группы не отдаём. | |
| await bot._tg_call(message.reply, bot._t(message.chat.id, "logs_group_only")) | |
| return | |
| # Флашим хендлеры _LOG_LISTENER (у root — только QueueHandler с no-op flush). | |
| try: | |
| if bot._LOG_LISTENER is not None: | |
| for handler in bot._LOG_LISTENER.handlers: | |
| handler.flush() | |
| except Exception: | |
| pass | |
| try: | |
| log_content = "" | |
| if bot.LOG_FILE_PATH.exists(): | |
| with open(bot.LOG_FILE_PATH, "r", encoding="utf-8", errors="ignore") as f: | |
| log_content = f.read() | |
| if not log_content or len(log_content.strip()) == 0: | |
| await bot._tg_call(message.reply, bot._t(message.chat.id, "logs_empty")) | |
| return | |
| # вычищаем токены из логов перед отправкой — единый список, см. _redactable_secrets | |
| for secret in bot._redactable_secrets(): | |
| log_content = log_content.replace(secret, "<REDACTED>") | |
| # пишем во временный файл, чтобы не ловить блокировку на живом логе | |
| fd, temp_log_path = tempfile.mkstemp(prefix="logs_", suffix=".txt") | |
| try: | |
| with os.fdopen(fd, "w", encoding="utf-8", errors="ignore") as f: | |
| f.write(log_content) | |
| await bot._tg_call(message.reply_document, FSInputFile(temp_log_path, filename="logs.txt")) | |
| finally: | |
| try: | |
| os.unlink(temp_log_path) | |
| except Exception: | |
| pass | |
| except Exception as exc: | |
| log.exception("Error extracting or sending logs:") | |
| await bot._tg_call(message.reply, bot._t(message.chat.id, "logs_send_error", error=exc)) | |
| async def cmd_stats(message: Message) -> None: | |
| """Статистика (скрыта, только владелец — данные по всем чатам).""" | |
| import bot | |
| is_owner = bot._is_owner(message.from_user.id if message.from_user else None) | |
| if not is_owner: | |
| await bot._tg_call(message.reply, bot._t(message.chat.id, "stats_deny")) | |
| return | |
| if message.chat.type != ChatType.PRIVATE: | |
| # ID моделей в группы не отдаём — та же проверка, что в /logs. | |
| await bot._tg_call(message.reply, bot._t(message.chat.id, "stats_group_only")) | |
| return | |
| # Счётчики — по датам Google (сброс в _reset_quota_if_new_day проверяем перед отрисовкой: фоновый тик мог не успеть, а used копились через рестарты). | |
| bot._reset_quota_if_new_day() | |
| # Вебхук опрашиваем параллельно со сборкой текста: иначе висящий API | |
| # держал бы всю команду до 10с уже после готовой статистики. | |
| _webhook_task = asyncio.ensure_future(_webhook_info_text()) | |
| total_chats = len(bot.chat_state) | |
| # "Активные" — с живой активностью за сутки по настенным часам (monotonic | |
| # сбрасывался рестартом и считал активными всех). Без метки — неактивен. | |
| _active_cutoff = time.time() - 24 * 3600 | |
| def _is_active(s: Any) -> bool: | |
| ts = s.get("last_activity") if isinstance(s, dict) else None | |
| return isinstance(ts, (int, float)) and not isinstance(ts, bool) and ts >= _active_cutoff | |
| active_chats = sum(1 for s in bot.chat_state.values() if _is_active(s)) | |
| uptime_sec = int(time.monotonic() - bot._PROCESS_START_MONOTONIC) | |
| uptime_str = f"{uptime_sec // 3600}ч {(uptime_sec % 3600) // 60}м" | |
| gemini_quota = bot.GLOBAL_QUOTA.get("gemini", {}) | |
| _stats_lang = bot._chat_lang(message.chat.id) | |
| _limitTag = _lang_t(_stats_lang, "stats_limit_used") | |
| _noData = _lang_t(_stats_lang, "stats_no_data") | |
| def _quota_section(quota: dict, daily_limit: int | None, per_model_limits: dict | None = None) -> str: | |
| # Показываем только модели с движением (расход или метка исчерпания) — нули по | |
| # давно мёртвым моделям копятся в хранилище через рестарты и превращали вывод в простыню. | |
| items = sorted(quota.items(), key=lambda kv: -(kv[1].get("used") or 0)) | |
| used_total = sum(int(e.get("used") or 0) for _, e in items) | |
| active = [(mid, e) for mid, e in items if (e.get("used") or 0) or e.get("exhausted_at")] | |
| lines = [] | |
| for mid, e in active: | |
| line = f" • {mid}: {e.get('used', 0)}" | |
| # Лимит Gemini — только где задан в env (у каждой модели свой RPD). | |
| _mlim = (per_model_limits or {}).get(mid) | |
| if isinstance(_mlim, int) and not isinstance(_mlim, bool) and _mlim > 0: | |
| line += f" / {_mlim}" | |
| if e.get('exhausted_at'): | |
| line += _limitTag | |
| lines.append(line) | |
| idle = len(items) - len(active) | |
| if idle: | |
| lines.append(f" …и ещё {idle} без обращений") | |
| total = f" Σ: {used_total}" | |
| if daily_limit is not None: | |
| total += f" / {daily_limit} (осталось {max(0, daily_limit - used_total)})" | |
| lines.append(total) | |
| return "\n".join(lines) or _noData | |
| gemini_text = _quota_section(gemini_quota, None, bot.GEMINI_DAILY_LIMITS) | |
| or_text = _quota_section(bot.GLOBAL_QUOTA.get("openrouter", {}), bot.OPENROUTER_DAILY_LIMIT) | |
| groq_text = _quota_section(bot.GLOBAL_QUOTA.get("groq", {}), bot.GROQ_DAILY_LIMIT) | |
| # Секция Munito только при включённом эксперименте: пустая строка в /stats не нужна. | |
| from lumen_router_config import _munito_active | |
| munito_block = ( | |
| f"<b>Munito — запросов по моделям (лимит провайдера справочно):</b>\n" | |
| f"{_quota_section(bot.GLOBAL_QUOTA.get('munito', {}), bot.MUNITO_DAILY_LIMIT)}\n\n" | |
| if _munito_active() else "" | |
| ) | |
| _quarantined_now = bot._quarantine_status() | |
| if _quarantined_now: | |
| quarantine_text = "\n".join( | |
| f" • {provider}/{mid}: {bad} плохих подряд (до конца суток)" for provider, mid, bad in _quarantined_now | |
| ) | |
| else: | |
| quarantine_text = " нет" | |
| # Суточные счётчики: уникальные пользователи — только числом, без ID. | |
| _day_stats = bot._stats_entry() | |
| _day_users = bot.GLOBAL_QUOTA.get(bot.USER_DAILY_KEY) | |
| day_users = len(_day_users) if isinstance(_day_users, dict) else 0 | |
| # Состояние прокси — из самого breaker'а (раньше команда лезла в четыре глобала напрямую). | |
| proxy_line = bot._tg_proxy_breaker.status_text() | |
| quota_day = bot.GLOBAL_QUOTA.get("quota_day") or "—" | |
| webhook_text = await _webhook_task | |
| text = ( | |
| f"<b>Статистика Lumen</b>\n" | |
| f"Сборка: {_build_version()}\n" | |
| f"Активных чатов (24ч): {active_chats} (всего: {total_chats})\n" | |
| f"Аптайм процесса: {uptime_str}\n" | |
| f"Счётчики квоты за сутки: {quota_day} (America/Los_Angeles, сбрасываются автоматически)\n" | |
| f"Пользователей за сутки: {day_users}\n" | |
| f"Сообщений получено: {_day_stats.get('messages_received', 0)}, ответов отправлено: {_day_stats.get('answers_sent', 0)}\n" | |
| f"Все модели отказали: {_day_stats.get('all_failed', 0)}, " | |
| f"переключений на резерв: {_day_stats.get('fallbacks', 0)}, " | |
| f"отказов по лимиту: {_day_stats.get('daily_limit_denials', 0)}\n" | |
| f"Попыток к моделям: {_day_stats.get('model_attempts', 0)}, " | |
| f"пустых: {_day_stats.get('model_failures', 0)}\n\n" | |
| f"<b>Gemini — запросов по моделям (лимит провайдера справочно):</b>\n{gemini_text}\n\n" | |
| f"{munito_block}" | |
| f"<b>OpenRouter — запросов по моделям (лимит провайдера справочно):</b>\n{or_text}\n\n" | |
| f"<b>Groq — запросов по моделям (лимит провайдера справочно):</b>\n{groq_text}\n\n" | |
| f"<b>Карантин моделей:</b>\n{quarantine_text}\n\n" | |
| f"Вебхук: {webhook_text}\n" | |
| f"Память: {_process_memory_text()}\n" | |
| f"Хранилище: {_storage_backend_text()}" | |
| f"{proxy_line}" | |
| ) | |
| # Короткий вывод для одного сообщения: режем по целым строкам, чтобы не | |
| # разорвать HTML-тег и не получить 400 от Telegram при parse_mode=HTML. | |
| if len(text) > bot.TG_MAX_LEN: | |
| cut = text[:bot.TG_MAX_LEN - 1] | |
| nl = cut.rfind("\n") | |
| if nl > bot.TG_MAX_LEN // 2: | |
| cut = cut[:nl] | |
| # Оборванный тег (<b ... без >) — откатываемся до его начала. | |
| lt, gt = cut.rfind("<"), cut.rfind(">") | |
| if lt > gt: | |
| cut = cut[:lt] | |
| text = cut.rstrip() + "…" | |
| await bot._tg_call(message.reply, text, parse_mode=ParseMode.HTML) | |
| _SELFTEST_LAST_RUN_MONOTONIC = 0.0 | |
| SELFTEST_COOLDOWN_SEC = 60.0 | |
| def _selftest_row(label: str, ok: bool, detail: str, elapsed: float) -> str: | |
| """Одна строка итога без ID моделей (в группы команда не ходит вовсе).""" | |
| base = f"{label}: {'OK' if ok else 'FAIL'} ({elapsed:.1f}s)" | |
| return base if ok or not detail else f"{base} — {detail}" | |
| async def cmd_selftest(message: Message) -> None: | |
| """Живая проверка голов маршрутов: по крошечной пробе в Groq, лёгкий OpenRouter | |
| и сеть (прокси Telegram, TikWM) логикой /diag. Gemini — только по явному | |
| аргументу, квота самая дефицитная. Только владелец, только личка, не чаще | |
| раза в минуту. Успешные пробы честно пишутся в квоту.""" | |
| import bot | |
| global _SELFTEST_LAST_RUN_MONOTONIC | |
| if not bot._is_owner(message.from_user.id if message.from_user else None): | |
| await bot._tg_call(message.reply, bot._t(message.chat.id, "stats_deny")) | |
| return | |
| if message.chat.type != ChatType.PRIVATE: | |
| await bot._tg_call(message.reply, bot._t(message.chat.id, "selftest_group_only")) | |
| return | |
| now = time.monotonic() | |
| # Ноль — «ещё ни разу», а не время: monotonic() свежего процесса тоже | |
| #тики от загрузки, и 60 − 45 давали ложный кулдаун первому запуску (CI). | |
| if _SELFTEST_LAST_RUN_MONOTONIC > 0: | |
| wait = SELFTEST_COOLDOWN_SEC - (now - _SELFTEST_LAST_RUN_MONOTONIC) | |
| if wait > 0: | |
| await bot._tg_call(message.reply, bot._t(message.chat.id, "selftest_cooldown", sec=int(wait) + 1)) | |
| return | |
| # Метку ставим до проб: повторный вызов во время долгой проверки тоже ждёт. | |
| _SELFTEST_LAST_RUN_MONOTONIC = now | |
| args = (getattr(message, "text", "") or "").split() | |
| want_gemini = len(args) > 1 and args[1].split("@")[0].lower() == "gemini" | |
| rows: list[str] = [] | |
| summary: list[str] = [] | |
| from lumen_router_config import _munito_active | |
| # Munito в списке только когда эксперимент включён — иначе /selftest тратит пробу впустую. | |
| probe_providers = (("munito", "Munito"), ("groq", "Groq"), ("openrouter", "OpenRouter")) if _munito_active() \ | |
| else (("groq", "Groq"), ("openrouter", "OpenRouter")) | |
| for provider, label in probe_providers: | |
| ok, detail, elapsed = await bot.selftest_llm_head(provider, chat_id=message.chat.id) | |
| rows.append(_selftest_row(label, ok, detail, elapsed)) | |
| summary.append(f"{provider}={'ok' if ok else 'FAIL'}/{elapsed:.1f}s") | |
| if want_gemini: | |
| ok, detail, elapsed = await bot.selftest_llm_head("gemini", chat_id=message.chat.id) | |
| rows.append(_selftest_row("Gemini", ok, detail, elapsed)) | |
| summary.append(f"gemini={'ok' if ok else 'FAIL'}/{elapsed:.1f}s") | |
| try: | |
| session = await bot._get_http_session() | |
| token = bot.BOT_TOKEN or "" | |
| proxy_url = bot.TELEGRAM_API_BASE_URL + "/bot" + (token[:6] if token else "x") + "/getMe" | |
| proxy_headers = {"X-Lumen-Proxy-Secret": bot.LUMEN_PROXY_SECRET} if bot.LUMEN_PROXY_SECRET else None | |
| tg_headers = proxy_headers if bot.TELEGRAM_API_BASE_URL != "https://api.telegram.org" else None | |
| # TikWM из HF доступен только через прокси: проверяем настроенный адрес | |
| # и путь /api/, как у реальных запросов, а не прямой хост (аудит 28.10.2026). | |
| from urllib.parse import urlsplit | |
| tikwm_base = bot.TIKWM_API_BASE_URL | |
| tikwm_via_proxy = bool(tikwm_base) and urlsplit(tikwm_base).hostname not in {"www.tikwm.com", "tikwm.com"} | |
| tikwm_headers = proxy_headers if tikwm_via_proxy else None | |
| net = await asyncio.gather( | |
| bot.probe_url(session, proxy_url, timeout_sec=6.0, redact=token, headers=tg_headers), | |
| bot.probe_url( | |
| session, (tikwm_base + "/api/") if tikwm_via_proxy else "https://www.tikwm.com", | |
| timeout_sec=6.0, headers=tikwm_headers, | |
| ), | |
| ) | |
| except Exception as exc: | |
| net = [ | |
| {"ok": False, "elapsed_sec": 0.0, "error": f"{exc.__class__.__name__}"}, | |
| {"ok": False, "elapsed_sec": 0.0, "error": f"{exc.__class__.__name__}"}, | |
| ] | |
| for label, res in (("TG-proxy", net[0]), ("TikWM", net[1])): | |
| detail = "" if res.get("ok") else str(res.get("error") or res.get("status") or "error")[:120] | |
| rows.append(_selftest_row(label, bool(res.get("ok")), detail, float(res.get("elapsed_sec") or 0.0))) | |
| summary.append(f"{label}={'ok' if res.get('ok') else 'FAIL'}") | |
| text = bot._t(message.chat.id, "selftest_header") + "\n" + "\n".join(rows) | |
| if not want_gemini: | |
| text += "\n" + bot._t(message.chat.id, "selftest_gemini_skipped") | |
| await bot._tg_call(message.reply, text) | |
| log.info("[selftest] chat=%s gemini_arg=%s %s", message.chat.id, want_gemini, " ".join(summary)) | |
| def _ban_target_id(message: Message) -> int | None: | |
| """ID цели /ban//unban: ответ на сообщение или числовой аргумент.""" | |
| replied = getattr(message, "reply_to_message", None) | |
| replied_user = getattr(replied, "from_user", None) if replied is not None else None | |
| replied_id = getattr(replied_user, "id", None) | |
| if isinstance(replied_id, int) and not isinstance(replied_id, bool): | |
| return replied_id | |
| args = (getattr(message, "text", "") or "").split()[1:] | |
| if args: | |
| raw = args[0].split("@")[0].lstrip("+") | |
| if raw.isdigit(): | |
| return int(raw) | |
| return None | |
| async def cmd_ban(message: Message) -> None: | |
| """Владельческая блокировка (только владелец, только личка).""" | |
| import bot | |
| if not bot._is_owner(message.from_user.id if message.from_user else None): | |
| await bot._tg_call(message.reply, bot._t(message.chat.id, "stats_deny")) | |
| return | |
| if message.chat.type != ChatType.PRIVATE: | |
| await bot._tg_call(message.reply, bot._t(message.chat.id, "ban_group_only")) | |
| return | |
| target = _ban_target_id(message) | |
| if target is None: | |
| await bot._tg_call(message.reply, bot._t(message.chat.id, "ban_usage")) | |
| return | |
| if bot._is_owner(target): | |
| await bot._tg_call(message.reply, bot._t(message.chat.id, "ban_owner_refuse")) | |
| return | |
| bot._ban_user(target) | |
| await bot._tg_call(message.reply, bot._t(message.chat.id, "ban_done", user_id=target)) | |
| log.info("[ban] Owner banned user %s", target) | |
| async def cmd_unban(message: Message) -> None: | |
| """Снятие владельческой блокировки (только владелец, только личка).""" | |
| import bot | |
| if not bot._is_owner(message.from_user.id if message.from_user else None): | |
| await bot._tg_call(message.reply, bot._t(message.chat.id, "stats_deny")) | |
| return | |
| if message.chat.type != ChatType.PRIVATE: | |
| await bot._tg_call(message.reply, bot._t(message.chat.id, "ban_group_only")) | |
| return | |
| target = _ban_target_id(message) | |
| if target is None: | |
| await bot._tg_call(message.reply, bot._t(message.chat.id, "ban_usage")) | |
| return | |
| if bot._unban_user(target): | |
| await bot._tg_call(message.reply, bot._t(message.chat.id, "unban_done", user_id=target)) | |
| log.info("[ban] Owner unbanned user %s", target) | |
| else: | |
| await bot._tg_call(message.reply, bot._t(message.chat.id, "unban_missing", user_id=target)) | |
| async def cmd_banlist(message: Message) -> None: | |
| """Список заблокированных (только владелец, только личка).""" | |
| import bot | |
| if not bot._is_owner(message.from_user.id if message.from_user else None): | |
| await bot._tg_call(message.reply, bot._t(message.chat.id, "stats_deny")) | |
| return | |
| if message.chat.type != ChatType.PRIVATE: | |
| await bot._tg_call(message.reply, bot._t(message.chat.id, "ban_group_only")) | |
| return | |
| ids = bot._banned_list() | |
| if not ids: | |
| await bot._tg_call(message.reply, bot._t(message.chat.id, "banlist_empty")) | |
| return | |
| text = bot._t(message.chat.id, "banlist_header") + "\n" + "\n".join(f"• {uid}" for uid in ids) | |
| # Список растёт руками, но упереться в лимит Telegram не должен: режем по | |
| # строкам, как вывод /stats выше. | |
| if len(text) > bot.TG_MAX_LEN: | |
| cut = text[:bot.TG_MAX_LEN - 1] | |
| nl = cut.rfind("\n") | |
| if nl > bot.TG_MAX_LEN // 2: | |
| cut = cut[:nl] | |
| text = cut.rstrip() + "…" | |
| await bot._tg_call(message.reply, text) | |
| _BUILD_VERSION_CACHED: str | None = None | |
| def _build_version() -> str: | |
| """Короткий хеш сборки для /stats: env сборки, иначе git, иначе unknown.""" | |
| global _BUILD_VERSION_CACHED | |
| # git-вызов блокирует loop до 5с — считаем один раз за жизнь процесса. | |
| if _BUILD_VERSION_CACHED is not None: | |
| return _BUILD_VERSION_CACHED | |
| for env_name in ("LUMEN_BUILD_VERSION", "BUILD_VERSION", "GIT_COMMIT", "COMMIT_SHA"): | |
| raw = os.getenv(env_name, "").strip() | |
| if raw: | |
| _BUILD_VERSION_CACHED = raw[:12] | |
| return _BUILD_VERSION_CACHED | |
| try: | |
| proc = subprocess.run( | |
| ["git", "rev-parse", "--short", "HEAD"], | |
| capture_output=True, text=True, timeout=5, check=False, | |
| ) | |
| sha = (proc.stdout or "").strip() | |
| if proc.returncode == 0 and sha: | |
| _BUILD_VERSION_CACHED = sha[:12] | |
| return _BUILD_VERSION_CACHED | |
| except Exception: | |
| pass | |
| _BUILD_VERSION_CACHED = "unknown" | |
| return "unknown" | |
| _WEBHOOK_INFO_TIMEOUT_SEC = 10.0 | |
| async def _webhook_info_text() -> str: | |
| """Строка про вебхук для /stats: очередь и последняя ошибка, иначе н/д.""" | |
| import bot | |
| try: | |
| if bot.bot is None: | |
| return "н/д" | |
| info = await asyncio.wait_for(bot.bot.get_webhook_info(), timeout=_WEBHOOK_INFO_TIMEOUT_SEC) | |
| except Exception: | |
| # Сбой/таймаут не роняют всю статистику: показываем н/д. | |
| return "н/д" | |
| try: | |
| pending = getattr(info, "pending_update_count", None) | |
| if isinstance(pending, bool) or not isinstance(pending, int): | |
| return "н/д" | |
| err_msg = (getattr(info, "last_error_message", None) or "").strip() | |
| if len(err_msg) > 120: | |
| err_msg = err_msg[:117] + "..." | |
| if err_msg: | |
| # last_error_message — текст Telegram (часто обрывок HTML-страницы): без | |
| # экранирования /stats ловил 400 и молчал ровно при сломанном вебхуке | |
| # (аудит 01.11.2026). | |
| return f"pending={pending}, ошибка: {html.escape(err_msg)}" | |
| return f"pending={pending}, ошибок нет" | |
| except Exception: | |
| return "н/д" | |
| def _process_memory_text() -> str: | |
| """RSS процесса для /stats: сначала текущий (Linux), иначе пик, иначе н/д.""" | |
| try: | |
| with open("/proc/self/statm", encoding="utf-8") as fh: | |
| rss_pages = int(fh.read().split()[1]) | |
| mb = rss_pages * os.sysconf("SC_PAGE_SIZE") / (1024 * 1024) | |
| return f"{mb:.0f} МБ" | |
| except Exception: | |
| pass | |
| try: | |
| import resource | |
| rss = float(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) | |
| # macOS отдаёт байты, Linux — килобайты; на Windows модуля нет вообще. | |
| if getattr(os, "uname", None) is not None and os.uname().sysname == "Darwin": | |
| rss /= 1024 * 1024 | |
| else: | |
| rss /= 1024 | |
| return f"{rss:.0f} МБ" | |
| except Exception: | |
| return "н/д" | |
| def _storage_backend_text() -> str: | |
| """Бэкенд хранилища и время последней успешной записи для /stats.""" | |
| import bot | |
| backend = "Upstash" if bot.USE_UPSTASH else "локальный диск" | |
| ts = bot._last_storage_write_ts() | |
| if isinstance(ts, (int, float)) and not isinstance(ts, bool) and ts > 0: | |
| when = time.strftime("%d.%m %H:%M", time.localtime(ts)) | |
| return f"{backend}, запись: {when}" | |
| return f"{backend}, записей пока не было" | |
| async def _send_pick_question(message: Message, scenario: str, original_text: str) -> None: | |
| """Отправляет уточняющий вопрос с кнопками и запоминает контекст выбора. | |
| token в callback_data короткий (лимит Telegram — 64 байта на всю строку).""" | |
| import bot | |
| _purge_expired_picks() | |
| _enforce_pending_picks_cap() | |
| token = secrets.token_hex(8) | |
| lang = bot._chat_lang(message.chat.id) | |
| bot._pending_picks[token] = { | |
| "chat_id": message.chat.id, | |
| "user_id": message.from_user.id if message.from_user else None, | |
| "scenario": scenario, | |
| "original": original_text, | |
| "expires": time.monotonic() + PICK_TTL_SEC, | |
| "lang": lang, | |
| } | |
| question, options, _tpl = pick_texts(lang, scenario) | |
| keyboard = InlineKeyboardMarkup(inline_keyboard=[ | |
| [InlineKeyboardButton(text=option, callback_data=f"pick:{token}:{idx}")] | |
| for idx, option in enumerate(options) | |
| ]) | |
| await bot._tg_call( | |
| message.reply, | |
| question + bot._t(message.chat.id, "pick_suffix"), | |
| reply_markup=keyboard, | |
| ) | |
| async def handle_pick_callback(query: CallbackQuery) -> None: | |
| """Кнопки-уточнения: чужие отклоняем, протухшие известные перевыпускаем разок, выбор дописываем к запросу и гоним обычным путём (_handle_message_core).""" | |
| import bot | |
| if getattr(query, "from_user", None) is not None and bot._is_banned(query.from_user.id): | |
| # Забаненный и кнопками не отвечает: иначе игнор обходился живыми пиками. | |
| with contextlib.suppress(Exception): | |
| await query.answer() | |
| return | |
| data = query.data or "" | |
| if not data.startswith("pick:"): | |
| return | |
| parts = data.split(":") | |
| if len(parts) != 3: | |
| with contextlib.suppress(Exception): | |
| await query.answer() | |
| return | |
| _, token, idx_raw = parts | |
| try: | |
| idx = int(idx_raw) | |
| except (TypeError, ValueError): | |
| with contextlib.suppress(Exception): | |
| await query.answer() | |
| return | |
| # Сначала все проверки по записи, удаление — только перед делом: чужой тап | |
| # или кривой индекс больше не сжигают кнопку владельца. Атомарность та же, | |
| # что у прежнего pop-first: всё синхронно до первого await ниже. | |
| rec = bot._pending_picks.get(token) | |
| # Язык для служебных реплик: из записи (если есть), иначе из чата кнопки. | |
| # Фолбэк без создания записи: обычный _chat_lang через get_state заводил бы чат | |
| # даже на чужой тап по неизвестному токену. | |
| _qchat = query.message.chat.id if query.message and query.message.chat else None | |
| rec_lang = (rec or {}).get("lang") or bot._peek_chat_lang(_qchat) | |
| def _is_rec_owner(rec_rec: dict) -> bool: | |
| """Чей это выбор. Запись без user_id (пост канала/аноним) принадлежит чату: | |
| иначе любой участник нажал бы чужую кнопку и сжёг токен (аудит 26.09.2026).""" | |
| owner_id = rec_rec.get("user_id") | |
| if owner_id is not None and query.from_user is not None and query.from_user.id != owner_id: | |
| return False | |
| if owner_id is None and query.message is not None and query.message.chat is not None: | |
| return query.message.chat.id == rec_rec.get("chat_id") | |
| return True | |
| if rec is not None and not _is_rec_owner(rec): | |
| # Проверка авторства ДО перевыпуска: иначе чужой тап по протухшей кнопке | |
| # продлевал бы чужой выбор новыми кнопками (враждебное ревью 27.09.2026). | |
| with contextlib.suppress(Exception): | |
| await query.answer(_lang_t(rec_lang, "pick_not_yours"), show_alert=True) | |
| return | |
| if rec is None or rec["expires"] < time.monotonic(): | |
| if rec is not None and query.message is not None: | |
| # Протухший известный выбор — молча свежие кнопки вместо стены текста. | |
| # Один ресенд: второй тап упрётся в rec None и честно покажет expired. | |
| bot._pending_picks.pop(token, None) | |
| _purge_expired_picks() | |
| _enforce_pending_picks_cap() | |
| fresh = secrets.token_hex(8) | |
| bot._pending_picks[fresh] = { | |
| "chat_id": rec.get("chat_id"), | |
| "user_id": rec.get("user_id"), | |
| "scenario": rec.get("scenario", ""), | |
| "original": rec.get("original", ""), | |
| "expires": time.monotonic() + PICK_TTL_SEC, | |
| "lang": rec_lang, | |
| } | |
| _rq, _ropts, _tpl = pick_texts(rec_lang, rec.get("scenario", "")) | |
| with contextlib.suppress(Exception): | |
| await query.answer() | |
| with contextlib.suppress(Exception): | |
| await bot._tg_call( | |
| query.message.edit_text, | |
| _rq + bot._t(rec.get("chat_id"), "pick_suffix"), | |
| parse_mode=None, call_timeout=15.0, | |
| reply_markup=InlineKeyboardMarkup(inline_keyboard=[ | |
| [InlineKeyboardButton(text=opt, callback_data=f"pick:{fresh}:{i}")] | |
| for i, opt in enumerate(_ropts) | |
| ]), | |
| ) | |
| return | |
| with contextlib.suppress(Exception): | |
| await query.answer(_lang_t(rec_lang, "pick_expired"), show_alert=False) | |
| return | |
| _q, options, tpl = pick_texts(rec_lang, rec.get("scenario", "")) | |
| if not 0 <= idx < len(options): | |
| with contextlib.suppress(Exception): | |
| await query.answer() | |
| return | |
| # Все проверки пройдены — только теперь забираем токен (см. комментарий у get выше). | |
| # Без сообщения кнопки не во что упереть: токен не трогаем, иначе тап из инлайн | |
| # режима сжёг бы чужой выбор без дела. | |
| question_msg = query.message | |
| if question_msg is None: | |
| with contextlib.suppress(Exception): | |
| await query.answer() | |
| return | |
| bot._pending_picks.pop(token, None) | |
| choice = options[idx] | |
| with contextlib.suppress(Exception): | |
| await query.answer() | |
| with contextlib.suppress(Exception): | |
| # Клавиатуру снимаем пустой разметкой, иначе кнопки повиснут (повторный тап всё равно упрётся в pop). | |
| await bot._tg_call( | |
| question_msg.edit_text, | |
| f"{_q}\n\n{_lang_t(rec_lang, 'pick_choice', choice=choice)}", | |
| parse_mode=None, call_timeout=15.0, | |
| reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), | |
| ) | |
| augmented = tpl.format(original=rec["original"], choice=choice) | |
| chat = question_msg.chat | |
| from_user = query.from_user | |
| async def _pick_reply(text: str, **kwargs: Any) -> Any: | |
| return await bot._tg_call( | |
| bot.bot.send_message, chat_id=chat.id, text=text, | |
| reply_to_message_id=getattr(question_msg, "message_id", None), | |
| **kwargs, | |
| ) | |
| ns = SimpleNamespace( | |
| text=augmented, caption=None, chat=chat, from_user=from_user, | |
| sender_chat=None, reply_to_message=None, | |
| message_id=getattr(question_msg, "message_id", None), | |
| reply=_pick_reply, | |
| ) | |
| # Флаг против зацикливания: дополненный текст всё ещё матчит детектор — без флага снова ушёл бы в кнопки. | |
| ns._pick_resolved = True | |
| try: | |
| # Тот же лимит лока, что и в основном пути (bot.CHAT_LOCK_TIMEOUT_SEC): | |
| # третье значение в 10с отдавало «занято» на любом живом маршруте. | |
| lock = await bot.acquire_chat_lock(chat.id, bot.CHAT_LOCK_TIMEOUT_SEC) | |
| except asyncio.TimeoutError: | |
| log.warning("[pick] Timeout waiting for lock on chat %s", chat.id) | |
| with contextlib.suppress(Exception): | |
| await query.answer(_lang_t(rec_lang, "pick_lock_busy"), show_alert=True) | |
| return | |
| try: | |
| await bot._handle_message_core(ns) | |
| finally: | |
| with contextlib.suppress(Exception): | |
| lock.release() | |