SilverElixir commited on
Commit
4ab6cec
·
1 Parent(s): d124960

P2: extract lumen_commands.py (commands, TTS/draw pipelines, pick buttons) with bot.X re-exports (AUD-A-001)

Browse files
Files changed (2) hide show
  1. bot.py +47 -604
  2. lumen_commands.py +638 -0
bot.py CHANGED
@@ -25,7 +25,6 @@ from pathlib import Path
25
  from collections import deque
26
  from datetime import date, datetime
27
  from typing import Any
28
- from types import SimpleNamespace
29
  import urllib.request as _urllib_request
30
 
31
  import aiohttp
@@ -37,11 +36,6 @@ from aiogram.enums import ChatType, ParseMode
37
  from aiogram.filters import Command
38
  from aiogram.types import (
39
  BotCommand,
40
- BufferedInputFile,
41
- CallbackQuery,
42
- FSInputFile,
43
- InlineKeyboardButton,
44
- InlineKeyboardMarkup,
45
  InlineQueryResultArticle,
46
  InputRichMessage,
47
  InputTextMessageContent,
@@ -59,10 +53,7 @@ from system_prompt import SYSTEM_PROMPT
59
  # не трогаем — модель отвечает на языке собеседника.
60
  from lumen_lang import (
61
  DEFAULT_LANG,
62
- LANG_NAMES,
63
  SUPPORTED_LANGS,
64
- normalize_lang,
65
- pick_texts,
66
  t as _lang_t,
67
  )
68
 
@@ -1408,6 +1399,26 @@ __all__ = [
1408
  # Монотонный маркер троттлинга проверки даты (читают тесты).
1409
  "_last_quota_check_monotonic",
1410
  "_last_gemini_exhausted_alert_monotonic",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1411
  ]
1412
 
1413
  # Троттлинг для уведомления владельца о полном исчерпании квоты Gemini (см.
@@ -1738,487 +1749,36 @@ def message_mentions_bot(message: Message) -> bool:
1738
 
1739
  # команды бота
1740
 
1741
- @dp.message(Command("start"))
1742
- async def cmd_start(message: Message) -> None:
1743
- await _tg_call(
1744
- message.reply,
1745
- _t(message.chat.id, "start_text"),
1746
- parse_mode=ParseMode.HTML,
1747
- )
1748
-
1749
- async def inline_draw(message: Message, prompt: str) -> None:
1750
- status = await _tg_call(message.reply, _t(message.chat.id, "status_generating_image"))
1751
- try:
1752
- session = await _get_http_session()
1753
- # УБРАНО (аудит техдолга, 19 августа 2026): раньше основная модель бралась
1754
- # из ручного выбора пользователя (/imgmodel, команда удалена — см. README,
1755
- # "Автоматический выбор модели"). Теперь _pick_image_model сама подбирает
1756
- # модель по содержимому промпта на каждый вызов, без какого-либо состояния
1757
- # чата — тот же принцип, что уже применяется к тексту (_build_route).
1758
- primary_model = _pick_image_model(prompt)
1759
-
1760
- # Фолбэк-цепочка: пробуем сначала подобранную модель,
1761
- # при ошибке переключаемся на следующие по порядку из POLLINATIONS_IMAGE_MODELS
1762
- all_model_ids = list(POLLINATIONS_IMAGE_MODELS.keys())
1763
- fallback_chain = [primary_model] + [m for m in all_model_ids if m != primary_model]
1764
-
1765
- image_bytes = None
1766
- last_error = None
1767
- budget_exceeded = False
1768
- rate_limited = False
1769
- deadline = time.monotonic() + DRAW_TOTAL_BUDGET_SEC
1770
-
1771
- for attempt_model in fallback_chain:
1772
- if time.monotonic() > deadline:
1773
- budget_exceeded = True
1774
- log.warning(
1775
- '[draw] Overall time budget (%.0fs) exhausted before trying %s — stopping the fallback chain instead of trying the remaining models.',
1776
- DRAW_TOTAL_BUDGET_SEC, attempt_model,
1777
- )
1778
- break
1779
- try:
1780
- # Статус нейтральный, без названий моделей: бот не раскрывает
1781
- # внутреннюю реализацию (см. ИДЕНТИЧНОСТЬ в system_prompt.py).
1782
- # На первой попытке статус и так "Генерирую изображение" — не трогаем.
1783
- if attempt_model != primary_model:
1784
- await _edit_message_quietly(status, _t(message.chat.id, "status_taking_longer"))
1785
- image_bytes = await _pollinations_text_to_image(session, attempt_model, prompt)
1786
- break
1787
- except Exception as exc:
1788
- last_error = exc
1789
- txt = _error_text(exc).lower()
1790
- # 429 — перегрузка ВСЕГО сервиса (см. прод 17.09.2026: все 5 моделей
1791
- # вернули 429 подряд), а не одной модели — гонять остаток цепочки
1792
- # бессмысленно, сразу говорим пользователю подождать.
1793
- if "429" in txt or "rate" in txt or "too many" in txt:
1794
- log.warning("[draw] Service rate-limited (429) on model %s — stopping the fallback chain.", attempt_model)
1795
- rate_limited = True
1796
- break
1797
- # Пробуем следующую только при сетевых/серверных ошибках
1798
- if any(kw in txt for kw in ("cannot connect", "ssl:", "no address", "503", "502", "timeout", "host")):
1799
- log.warning("[draw] Model %s failed (%s), trying next fallback", attempt_model, type(exc).__name__)
1800
- continue
1801
- # При других ошибках (400, 404 и т.д.) тоже пробуем следующую
1802
- log.warning("[draw] Model %s error: %s, trying next", attempt_model, exc)
1803
- continue
1804
-
1805
- if image_bytes:
1806
- await _delete_message_quietly(status)
1807
- await bot.send_photo(
1808
- chat_id=message.chat.id,
1809
- photo=BufferedInputFile(image_bytes, filename="generated.jpg"),
1810
- reply_to_message_id=message.message_id,
1811
- )
1812
- else:
1813
- if rate_limited:
1814
- raise RuntimeError("image generation service overloaded with requests") from last_error
1815
- if budget_exceeded:
1816
- raise RuntimeError("image generation total time budget exceeded") from last_error
1817
- raise last_error or RuntimeError("all image generation models unavailable")
1818
-
1819
- except Exception as exc:
1820
- log.exception("Pollinations image generation failed:")
1821
- txt = _error_text(exc).strip()
1822
- cid = message.chat.id
1823
- if any(kw in txt.lower() for kw in ("cannot connect", "ssl:", "no address", "connection", "timeout", "host")):
1824
- user_err = _t(cid, "draw_err_unavailable")
1825
- elif "overloaded" in txt.lower():
1826
- user_err = _t(cid, "draw_err_overloaded")
1827
- elif "all image generation" in txt.lower():
1828
- user_err = _t(cid, "draw_err_gone")
1829
- elif "time budget" in txt.lower():
1830
- user_err = _t(cid, "draw_err_budget")
1831
- else:
1832
- # Сырой текст ошибки провайдера пользователю не показываем (см. log.exception
1833
- # выше) — та же логика, что и в остальных обработчиках ошибок бота.
1834
- user_err = _t(cid, "draw_err_generic")
1835
- await _edit_message_quietly(status, user_err)
1836
-
1837
-
1838
- @dp.message(Command("draw"))
1839
- async def cmd_draw(message: Message) -> None:
1840
- prompt = message.text.partition(" ")[2].strip() if message.text else ""
1841
- if not prompt:
1842
- await _safe_reply(message, _t(message.chat.id, "draw_empty"))
1843
- return
1844
- if await _reject_rate_limited_message(message):
1845
- return
1846
- await inline_draw(message, prompt)
1847
-
1848
- # ─────────────────── TTS-пайплайн (Fish Audio + Gemini TTS) ───────────────────
1849
- # НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА (разбиение bot.py на модули): сам синтез (Fish Audio
1850
- # через OpenRouter + резервная цепочка Gemini TTS, конвертация PCM->WAV) вынесен в
1851
- # lumen_tts.py — единственная связь этих функций с рантайм-состоянием бота (aiohttp-
1852
- # сессия, конфиг OpenRouter, клиент Gemini, учёт квоты) идёт через параметры, а не
1853
- # через собственные module-level globals в новом файле (см. докстринг lumen_tts.py).
1854
- # `_fish_audio_tts_bytes`/`_gemini_tts_bytes` ниже — тонкие обёртки с ТЕМИ ЖЕ именами
1855
- # и сигнатурами (только `text`), что были раньше: они читают актуальные значения
1856
- # СВОИХ модульных глобалов (в т.ч. те, что подменяют тесты через `bot.OPENROUTER_API_KEY
1857
- # = ...`/`bot.client = ...`) на каждый вызов и прокидывают их в lumen_tts — поведение
1858
- # и публичный интерфейс не изменились ни на йоту.
1859
- from lumen_tts import (
1860
- pcm_to_wav,
1861
- _fish_audio_tts_bytes as _lumen_fish_audio_tts_bytes,
1862
- _gemini_tts_bytes as _lumen_gemini_tts_bytes,
1863
  )
 
 
 
 
 
 
 
 
 
1864
 
1865
 
1866
- async def _fish_audio_tts_bytes(text: str) -> bytes | None:
1867
- session = await _get_http_session()
1868
- return await _lumen_fish_audio_tts_bytes(
1869
- session, text,
1870
- api_key=OPENROUTER_API_KEY, http_referer=OPENROUTER_HTTP_REFERER,
1871
- title=OPENROUTER_TITLE, base_url=OPENROUTER_BASE_URL,
1872
- model_id=FISH_AUDIO_TTS_MODEL, request_timeout_sec=ROUTE_MODEL_TIMEOUT_SEC,
1873
- )
1874
-
1875
-
1876
- async def _gemini_tts_bytes(text: str) -> tuple[bytes, str, str]:
1877
- def _is_rate_limit(e: Exception) -> bool:
1878
- err_txt = _error_text(e).strip() or e.__class__.__name__
1879
- return _classify_model_error(_error_status(e, err_txt), err_txt) == "rate_limit"
1880
-
1881
- # Пишем в тот же провайдер "gemini" — /stats уже показывает GLOBAL_QUOTA["gemini"]
1882
- # по всем моделям отсортированным по расходу, TTS-модели появляются там же.
1883
- return await _lumen_gemini_tts_bytes(
1884
- client, text, tts_models=GEMINI_TTS_MODELS,
1885
- is_rate_limit_error=_is_rate_limit,
1886
- on_model_exhausted=lambda mname: _mark_quota_exhausted("gemini", mname),
1887
- on_model_success=lambda mname: _record_quota_usage("gemini", mname),
1888
- )
1889
-
1890
-
1891
- async def inline_tts(message: Message, text: str) -> None:
1892
- if len(text) > TTS_MAX_CHARS:
1893
- await _safe_reply(
1894
- message,
1895
- _t(message.chat.id, "tts_too_long", limit=TTS_MAX_CHARS, length=len(text)),
1896
- )
1897
- return
1898
- status = await _tg_call(message.reply, _t(message.chat.id, "status_voicing"))
1899
- try:
1900
- # FISH_AUDIO_ENABLED=False (аудит моделей, 17.09.2026 — зеркало снято с
1901
- # бесплатного каталога OpenRouter): пропускаем заведомо мёртвую первую
1902
- # попытку и идём сразу на Gemini TTS. Ветка fish оставлена, не удалена —
1903
- # см. комментарий у флага в lumen_router_config.py.
1904
- fish_bytes = await _fish_audio_tts_bytes(text) if FISH_AUDIO_ENABLED else None
1905
- if fish_bytes is not None:
1906
- pcm_bytes, mime_type, used_tts_model = fish_bytes, "audio/mp3", FISH_AUDIO_TTS_MODEL
1907
- _record_quota_usage("openrouter", FISH_AUDIO_TTS_MODEL)
1908
- else:
1909
- pcm_bytes, mime_type, used_tts_model = await _gemini_tts_bytes(text)
1910
- log.info('[tts] Synthesis received from %s, mime_type=%s, bytes=%d', used_tts_model, mime_type, len(pcm_bytes))
1911
-
1912
- # определяем формат исходника
1913
- 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'):
1914
- src_ext = ".mp3"
1915
- raw_audio = pcm_bytes
1916
- elif pcm_bytes.startswith(b'RIFF') or "wav" in mime_type:
1917
- src_ext = ".wav"
1918
- raw_audio = pcm_bytes
1919
- else:
1920
- # сырой PCM сначала оборачиваем в WAV
1921
- src_ext = ".wav"
1922
- raw_audio = pcm_to_wav(pcm_bytes, sample_rate=24000)
1923
-
1924
- # конвертируем в OGG/Opus через ffmpeg — send_voice в Telegram без этого
1925
- # покажет длительность 0:00
1926
- final_audio = raw_audio
1927
- final_filename = "speech.ogg"
1928
- voice_duration = 0
1929
- try:
1930
- with tempfile.TemporaryDirectory() as tdir:
1931
- src_path = os.path.join(tdir, f"tts_src{src_ext}")
1932
- dst_path = os.path.join(tdir, "tts_out.ogg")
1933
- with open(src_path, "wb") as fh:
1934
- fh.write(raw_audio)
1935
- proc = await asyncio.create_subprocess_exec(
1936
- "ffmpeg", "-y", "-i", src_path,
1937
- "-c:a", "libopus", "-b:a", "64k", "-vbr", "on",
1938
- dst_path,
1939
- stdout=asyncio.subprocess.DEVNULL,
1940
- stderr=asyncio.subprocess.DEVNULL,
1941
- )
1942
- await _communicate_process(proc, timeout=30)
1943
- if os.path.exists(dst_path) and os.path.getsize(dst_path) > 0:
1944
- with open(dst_path, "rb") as fh:
1945
- final_audio = fh.read()
1946
- # ffprobe — получаем длительность для Telegram (без неё показывает 0:00)
1947
- try:
1948
- probe = await asyncio.create_subprocess_exec(
1949
- "ffprobe", "-v", "error",
1950
- "-show_entries", "format=duration",
1951
- "-of", "default=noprint_wrappers=1:nokey=1",
1952
- dst_path,
1953
- stdout=asyncio.subprocess.PIPE,
1954
- stderr=asyncio.subprocess.DEVNULL,
1955
- )
1956
- probe_out, _ = await _communicate_process(probe, timeout=10)
1957
- raw_dur = probe_out.decode().strip()
1958
- voice_duration = max(1, round(float(raw_dur))) if raw_dur else 0
1959
- except Exception as probe_exc:
1960
- log.warning("[tts] ffprobe failed: %s", probe_exc)
1961
- log.info("[tts] OGG/Opus: %d bytes, duration: %ds", len(final_audio), voice_duration)
1962
- else:
1963
- log.warning("[tts] ffmpeg OGG conversion failed, falling back to raw audio")
1964
- final_filename = f"speech{src_ext}"
1965
- except Exception as conv_exc:
1966
- log.warning("[tts] ffmpeg conversion error: %s", conv_exc)
1967
- final_filename = f"speech{src_ext}"
1968
-
1969
- await _delete_message_quietly(status)
1970
- await bot.send_voice(
1971
- chat_id=message.chat.id,
1972
- voice=BufferedInputFile(final_audio, filename=final_filename),
1973
- duration=voice_duration if voice_duration > 0 else None,
1974
- reply_to_message_id=message.message_id
1975
- )
1976
- except Exception as exc:
1977
- log.exception("TTS synthesis failed:")
1978
- # Сырой текст ошибки пользователю не показываем — он может содержать
1979
- # реальные ID моделей ("gemini-3.1-flash-tts-preview" и т.п.) или другие
1980
- # служебные детали. Используем ту же классификацию, что и для чата.
1981
- txt = _error_text(exc).strip() or exc.__class__.__name__
1982
- kind = _classify_model_error(_error_status(exc, txt), txt)
1983
- cid = message.chat.id
1984
- if kind == "rate_limit":
1985
- user_err = _t(cid, "tts_err_exhausted")
1986
- else:
1987
- user_err = _t(cid, "tts_err_generic")
1988
- await _edit_message_quietly(status, user_err)
1989
-
1990
- @dp.message(Command("tts"))
1991
- async def cmd_tts(message: Message) -> None:
1992
- text = message.text.partition(" ")[2].strip() if message.text else ""
1993
- if not text:
1994
- await _safe_reply(message, _t(message.chat.id, "tts_empty"))
1995
- return
1996
- if await _reject_rate_limited_message(message):
1997
- return
1998
- await inline_tts(message, text)
1999
-
2000
- @dp.message(Command("reset"))
2001
- async def cmd_reset(message: Message) -> None:
2002
- """Сбрасывает историю диалога в текущем чате. Намеренно скрыта: не добавлена
2003
- в setMyCommands и не упомянута в /start — чтобы не загромождать меню команд
2004
- (как и /logs). Доступ: в личных сообщениях — всем (это история только одного
2005
- человека), в группах — только администратору/создателю группы или владельцу
2006
- бота (сброс общей истории всей группы — не рядовое действие)."""
2007
- requester_id = message.from_user.id if message.from_user else None
2008
- if not await _is_privileged_in_chat(message.chat.type, message.chat.id, requester_id):
2009
- await _tg_call(
2010
- message.reply,
2011
- _t(message.chat.id, "reset_deny")
2012
- )
2013
- return
2014
- state = get_state(message.chat.id)
2015
- state["history"] = []
2016
- state["ctx"].clear()
2017
- mark_state_dirty(message.chat.id)
2018
- await _tg_call(
2019
- message.reply,
2020
- _t(message.chat.id, "reset_done")
2021
- )
2022
-
2023
- # ── Язык бота (/lang) ──
2024
- # Переключает язык СИСТЕМНЫХ сообщений бота в этом чате (/start, подсказки,
2025
- # ошибки, статусы — всё, что бот пишет сам). Ответы ИИ не трогает: модель
2026
- # отвечает на языке собеседника (см. RESPONSE LANGUAGE в system_prompt.py).
2027
- # Права — как у /reset (см. _is_privileged_in_chat): в личке меняет кто
2028
- # угодно, в группе — админ/создатель группы или владелец бота. Смотреть меню
2029
- # может любой; непривилегированный тап вежливо отклоняется. Кнопки без флагов
2030
- # (флаг ≠ язык) и без эмодзи, текущий язык помечен текстовой галочкой ✓.
2031
- # Языки в меню — по алфавиту кода (SUPPORTED_LANGS в lumen_lang.py).
2032
- @dp.message(Command("lang"))
2033
- async def cmd_lang(message: Message) -> None:
2034
- lang = _chat_lang(message.chat.id)
2035
- rows = []
2036
- codes = list(SUPPORTED_LANGS)
2037
- for i in range(0, len(codes), 2):
2038
- row = []
2039
- for code in codes[i:i + 2]:
2040
- mark = " ✓" if code == lang else ""
2041
- row.append(InlineKeyboardButton(
2042
- text=f"{LANG_NAMES[code]}{mark}",
2043
- callback_data=f"lang:{code}",
2044
- ))
2045
- rows.append(row)
2046
- await _tg_call(
2047
- message.reply,
2048
- _t(message.chat.id, "lang_title"),
2049
- reply_markup=InlineKeyboardMarkup(inline_keyboard=rows),
2050
- )
2051
-
2052
-
2053
- @dp.callback_query()
2054
- async def handle_lang_callback(query: CallbackQuery) -> None:
2055
- """Нажатие кнопки языка: проверка прав, сохранение, подтверждение на новом
2056
- языке. callback_data — "lang:<код>", короткие коды влезают в лимит 64 байт
2057
- с запасом."""
2058
- data = query.data or ""
2059
- if not data.startswith("lang:"):
2060
- return
2061
- parts = data.split(":")
2062
- if len(parts) != 2:
2063
- with contextlib.suppress(Exception):
2064
- await query.answer()
2065
- return
2066
- code = normalize_lang(parts[1])
2067
- question_msg = query.message
2068
- if question_msg is None or question_msg.chat is None:
2069
- with contextlib.suppress(Exception):
2070
- await query.answer()
2071
- return
2072
- chat = question_msg.chat
2073
- requester_id = query.from_user.id if query.from_user else None
2074
- if not await _is_privileged_in_chat(chat.type, chat.id, requester_id):
2075
- with contextlib.suppress(Exception):
2076
- await query.answer(_t(chat.id, "lang_deny"), show_alert=True)
2077
- return
2078
- state = get_state(chat.id)
2079
- state["lang"] = code
2080
- mark_state_dirty(chat.id)
2081
- with contextlib.suppress(Exception):
2082
- await query.answer()
2083
- with contextlib.suppress(Exception):
2084
- await _tg_call(
2085
- question_msg.edit_text,
2086
- _lang_t(code, "lang_done"),
2087
- parse_mode=None, call_timeout=15.0,
2088
- reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
2089
- )
2090
-
2091
- # выгрузка логов (только для владельца)
2092
-
2093
- @dp.message(Command("logs"))
2094
- async def cmd_logs(message: Message) -> None:
2095
- is_owner = _is_owner(message.from_user.id if message.from_user else None)
2096
-
2097
- if not is_owner:
2098
- await _tg_call(message.reply, _t(message.chat.id, "logs_deny"))
2099
- return
2100
-
2101
- if message.chat.type != ChatType.PRIVATE:
2102
- # Найдено при код-ревью: результат команды видят ВСЕ участники чата, в
2103
- # котором она вызвана, а не только владелец — файл логов содержит реальные
2104
- # технические детали (ID моделей и т.п.), которые не должны светиться в
2105
- # групповых чатах.
2106
- await _tg_call(message.reply, _t(message.chat.id, "logs_group_only"))
2107
- return
2108
-
2109
- # сбрасываем буфер логов на диск — НАЙДЕНО ПРИ АУДИТЕ ЛОГИРОВАНИЯ: после
2110
- # перехода на QueueHandler/QueueListener у root-логгера остался только сам
2111
- # QueueHandler (его flush() — no-op), реальные file_handler/console_handler
2112
- # живут внутри _LOG_LISTENER, а не на root — цикл по logging.getLogger().handlers
2113
- # ничего не флашил уже с момента этой миграции.
2114
- try:
2115
- if _LOG_LISTENER is not None:
2116
- for handler in _LOG_LISTENER.handlers:
2117
- handler.flush()
2118
- except Exception:
2119
- pass
2120
-
2121
- try:
2122
- log_content = ""
2123
- if LOG_FILE_PATH.exists():
2124
- with open(LOG_FILE_PATH, "r", encoding="utf-8", errors="ignore") as f:
2125
- log_content = f.read()
2126
-
2127
- if not log_content or len(log_content.strip()) == 0:
2128
- await _tg_call(message.reply, _t(message.chat.id, "logs_empty"))
2129
- return
2130
-
2131
- # вычищаем токены из логов перед отправкой — единый список, см. _redactable_secrets
2132
- for secret in _redactable_secrets():
2133
- log_content = log_content.replace(secret, "<REDACTED>")
2134
-
2135
- # пишем во временный файл, чтобы не ловить блокировку на живом логе
2136
- tmp_dir = tempfile.gettempdir()
2137
- temp_log_path = os.path.join(tmp_dir, "logs.txt")
2138
- with open(temp_log_path, "w", encoding="utf-8", errors="ignore") as f:
2139
- f.write(log_content)
2140
-
2141
- await _tg_call(message.reply_document, FSInputFile(temp_log_path, filename="logs.txt"))
2142
-
2143
- try:
2144
- os.unlink(temp_log_path)
2145
- except Exception:
2146
- pass
2147
- except Exception as exc:
2148
- log.exception("Error extracting or sending logs:")
2149
- await _tg_call(message.reply, _t(message.chat.id, "logs_send_error", error=exc))
2150
-
2151
- @dp.message(Command("stats"))
2152
- async def cmd_stats(message: Message) -> None:
2153
- """Глобальная статистика бота. Скрыта (не в setMyCommands, не в /start) и
2154
- доступна только владельцу — тот же принцип доступа, что и у /logs, т.к.
2155
- показывает данные по всем чатам, а не только текущему."""
2156
- is_owner = _is_owner(message.from_user.id if message.from_user else None)
2157
- if not is_owner:
2158
- await _tg_call(message.reply, _t(message.chat.id, "stats_deny"))
2159
- return
2160
-
2161
- if message.chat.type != ChatType.PRIVATE:
2162
- # См. аналогичную проверку в /logs — статистика содержит реальные ID
2163
- # моделей Gemini/OpenRouter, не должна светиться в групповых чатах.
2164
- await _tg_call(message.reply, _t(message.chat.id, "stats_group_only"))
2165
- return
2166
-
2167
- # Счётчики квоты — по датам America/Los_Angeles (полночь Google для RPD-лимитов),
2168
- # см. _reset_quota_if_new_day. Проверяем прямо перед отрисовкой /stats, чтобы
2169
- # владелец не увидел вчерашние числа, даже если часовой фоновый тик ещё не
2170
- # успел сработать (реальный найденный баг — см. историю: used-счётчики копились
2171
- # НАВСЕГДА через рестарты и не имели отношения к аптайму процесса).
2172
- _reset_quota_if_new_day()
2173
-
2174
- total_chats = len(chat_state)
2175
- uptime_sec = int(time.monotonic() - _PROCESS_START_MONOTONIC)
2176
- uptime_str = f"{uptime_sec // 3600}ч {(uptime_sec % 3600) // 60}м"
2177
-
2178
- gemini_quota = GLOBAL_QUOTA.get("gemini", {})
2179
- _stats_lang = _chat_lang(message.chat.id)
2180
- _limitTag = _lang_t(_stats_lang, "stats_limit_used")
2181
- _noData = _lang_t(_stats_lang, "stats_no_data")
2182
- gemini_lines = [
2183
- f" • {mid}: {e.get('used', 0)}{_limitTag if e.get('exhausted_at') else ''}"
2184
- for mid, e in sorted(gemini_quota.items(), key=lambda kv: -(kv[1].get("used") or 0))
2185
- ]
2186
- gemini_text = "\n".join(gemini_lines) or _noData
2187
-
2188
- # ИСПРАВЛЕНО (найдено при калибровке 25 июля 2026): раньше здесь была только
2189
- # ОДНА суммарная цифра запросов OpenRouter плюс денежный $-баланс аккаунта —
2190
- # для отладки роутинга и выявления "тупящих" моделей это бесполезно: не видно,
2191
- # КАКАЯ именно модель отвечала и сколько раз. Теперь — та же разбивка по
2192
- # моделям, что уже была у Gemini, отсортированная по расходу. $-баланс убран
2193
- # целиком: все модели в списке — :free, платный баланс тут ни на что не влияет
2194
- # и только замусоривал вывод.
2195
- or_quota = GLOBAL_QUOTA.get("openrouter", {})
2196
- or_lines = [
2197
- f" • {mid}: {e.get('used', 0)}{_limitTag if e.get('exhausted_at') else ''}"
2198
- for mid, e in sorted(or_quota.items(), key=lambda kv: -(kv[1].get("used") or 0))
2199
- ]
2200
- or_text = "\n".join(or_lines) or _noData
2201
-
2202
- # Видимость состояния "выключателя" Telegram-прокси прямо из Telegram, а не
2203
- # только по логам контейнера — иначе деградацию прокси можно было заметить
2204
- # только копаясь в логах HF Spaces (см. код-ревью, suggestion #1). Текст
2205
- # теперь собирает сам _tg_proxy_breaker (см. _TelegramProxyCircuitBreaker) —
2206
- # раньше эта команда лезла в четыре module-level globals напрямую.
2207
- proxy_line = _tg_proxy_breaker.status_text()
2208
-
2209
- quota_day = GLOBAL_QUOTA.get("quota_day") or "—"
2210
-
2211
- text = (
2212
- f"<b>Статистика Lumen</b>\n\n"
2213
- f"Активных чатов: {total_chats}\n"
2214
- f"Аптайм процесса: {uptime_str}\n"
2215
- f"Счётчики квоты за сутки: {quota_day} (America/Los_Angeles, сбрасываются автоматически)\n\n"
2216
- f"<b>Gemini — запросов по моделям:</b>\n{gemini_text}\n\n"
2217
- f"<b>OpenRouter — запросов по моделям:</b>\n{or_text}"
2218
- f"{proxy_line}"
2219
- )
2220
- await _tg_call(message.reply, text, parse_mode=ParseMode.HTML)
2221
-
2222
  # ─────────────────── автоматический выбор модели (роутер) ───────────────────
2223
  # Полностью заменяет ручной выбор через /model и /provider (обе команды удалены).
2224
  # Пользователь никогда явно не выбирает ни провайдера, ни модель — на КАЖДОЕ
@@ -2258,123 +1818,6 @@ PICK_BUTTONS_ENABLED = os.getenv("PICK_BUTTONS_ENABLED", "1") == "1"
2258
  # импортированы выше рядом с rate limit (P2), здесь используются напрямую.
2259
 
2260
 
2261
- async def _send_pick_question(message: Message, scenario: str, original_text: str) -> None:
2262
- """Отправляет уточняющий вопрос с кнопками и запоминает контекст выбора.
2263
- token в callback_data короткий (лимит Telegram — 64 байта на всю строку)."""
2264
- _purge_expired_picks()
2265
- _enforce_pending_picks_cap()
2266
- token = secrets.token_hex(4)
2267
- lang = _chat_lang(message.chat.id)
2268
- _pending_picks[token] = {
2269
- "chat_id": message.chat.id,
2270
- "user_id": message.from_user.id if message.from_user else None,
2271
- "scenario": scenario,
2272
- "original": original_text,
2273
- "expires": time.monotonic() + PICK_TTL_SEC,
2274
- "lang": lang,
2275
- }
2276
- question, options, _tpl = pick_texts(lang, scenario)
2277
- keyboard = InlineKeyboardMarkup(inline_keyboard=[
2278
- [InlineKeyboardButton(text=option, callback_data=f"pick:{token}:{idx}")]
2279
- for idx, option in enumerate(options)
2280
- ])
2281
- await _tg_call(
2282
- message.reply,
2283
- question + _t(message.chat.id, "pick_suffix"),
2284
- reply_markup=keyboard,
2285
- )
2286
-
2287
-
2288
- @dp.callback_query()
2289
- async def handle_pick_callback(query: CallbackQuery) -> None:
2290
- """Обработчик нажатий кнопок-уточнений. Чужие кнопки (другой пользователь
2291
- в группе) и протухшие/перезапущенные записи отклоняются вежливо, без
2292
- обработки. Выбор дописывается к исходному запросу по шаблону сценария и
2293
- уходит обычным путём через _handle_message_core — дальше роутер, стриминг
2294
- и история работают как для текстового сообщения."""
2295
- data = query.data or ""
2296
- if not data.startswith("pick:"):
2297
- return
2298
- parts = data.split(":")
2299
- if len(parts) != 3:
2300
- with contextlib.suppress(Exception):
2301
- await query.answer()
2302
- return
2303
- _, token, idx_raw = parts
2304
- try:
2305
- idx = int(idx_raw)
2306
- except (TypeError, ValueError):
2307
- with contextlib.suppress(Exception):
2308
- await query.answer()
2309
- return
2310
- # Pop сразу (а не после проверок): повторный тап по тем же кнопкам не
2311
- # должен порождать второй ответ. Чужая кнопка при этом "сгорает" — цена
2312
- # приемлема: владелец переспросит текстом.
2313
- rec = _pending_picks.pop(token, None)
2314
- # Язык для служебных реплик: из записи (если есть), иначе из чата кнопки.
2315
- _qchat = query.message.chat.id if query.message and query.message.chat else None
2316
- rec_lang = (rec or {}).get("lang") or _chat_lang(_qchat)
2317
- if rec is None or rec["expires"] < time.monotonic():
2318
- with contextlib.suppress(Exception):
2319
- await query.answer(_lang_t(rec_lang, "pick_expired"), show_alert=False)
2320
- return
2321
- _q, options, tpl = pick_texts(rec_lang, rec.get("scenario", ""))
2322
- if not 0 <= idx < len(options):
2323
- with contextlib.suppress(Exception):
2324
- await query.answer()
2325
- return
2326
- if rec["user_id"] is not None and query.from_user is not None and query.from_user.id != rec["user_id"]:
2327
- with contextlib.suppress(Exception):
2328
- await query.answer(_lang_t(rec_lang, "pick_not_yours"), show_alert=True)
2329
- return
2330
- choice = options[idx]
2331
- with contextlib.suppress(Exception):
2332
- await query.answer()
2333
- question_msg = query.message
2334
- if question_msg is None:
2335
- return
2336
- with contextlib.suppress(Exception):
2337
- # Клавиатуру снимаем пустой разметкой, иначе кнопки останутся висеть
2338
- # под сообщением (повторный тап при этом всё равно упрётся в pop выше).
2339
- await _tg_call(
2340
- question_msg.edit_text,
2341
- f"{_q}\n\n{_lang_t(rec_lang, 'pick_choice', choice=choice)}",
2342
- parse_mode=None, call_timeout=15.0,
2343
- reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
2344
- )
2345
- augmented = tpl.format(original=rec["original"], choice=choice)
2346
- chat = question_msg.chat
2347
- from_user = query.from_user
2348
-
2349
- async def _pick_reply(text: str, **kwargs: Any) -> Any:
2350
- return await _tg_call(
2351
- bot.send_message, chat_id=chat.id, text=text,
2352
- reply_to_message_id=getattr(question_msg, "message_id", None),
2353
- **kwargs,
2354
- )
2355
-
2356
- ns = SimpleNamespace(
2357
- text=augmented, caption=None, chat=chat, from_user=from_user,
2358
- sender_chat=None, reply_to_message=None,
2359
- message_id=getattr(question_msg, "message_id", None),
2360
- reply=_pick_reply,
2361
- )
2362
- # Флаг против зацикливания: дополненный текст всё ещё матчится детектором
2363
- # ("посоветуй фильм (жанр: ...)"), без флага ушёл бы снова в кнопки.
2364
- ns._pick_resolved = True
2365
- lock = get_chat_lock(chat.id)
2366
- try:
2367
- await asyncio.wait_for(lock.acquire(), timeout=10.0)
2368
- except asyncio.TimeoutError:
2369
- log.warning("[pick] Timeout waiting for lock on chat %s", chat.id)
2370
- with contextlib.suppress(Exception):
2371
- await query.answer(_lang_t(rec_lang, "pick_lock_busy"), show_alert=True)
2372
- return
2373
- try:
2374
- await _handle_message_core(ns)
2375
- finally:
2376
- with contextlib.suppress(Exception):
2377
- lock.release()
2378
 
2379
 
2380
 
 
25
  from collections import deque
26
  from datetime import date, datetime
27
  from typing import Any
 
28
  import urllib.request as _urllib_request
29
 
30
  import aiohttp
 
36
  from aiogram.filters import Command
37
  from aiogram.types import (
38
  BotCommand,
 
 
 
 
 
39
  InlineQueryResultArticle,
40
  InputRichMessage,
41
  InputTextMessageContent,
 
53
  # не трогаем — модель отвечает на языке собеседника.
54
  from lumen_lang import (
55
  DEFAULT_LANG,
 
56
  SUPPORTED_LANGS,
 
 
57
  t as _lang_t,
58
  )
59
 
 
1399
  # Монотонный маркер троттлинга проверки даты (читают тесты).
1400
  "_last_quota_check_monotonic",
1401
  "_last_gemini_exhausted_alert_monotonic",
1402
+ # Имена остальных вынесенных модулей — только для `bot.X` в тестах.
1403
+ "GEMINI_TTS_MODELS",
1404
+ "FISH_AUDIO_TTS_MODEL",
1405
+ "FISH_AUDIO_ENABLED",
1406
+ "POLLINATIONS_IMAGE_MODELS",
1407
+ "_pick_image_model",
1408
+ "_pollinations_text_to_image",
1409
+ "_communicate_process",
1410
+ "_fish_audio_tts_bytes",
1411
+ "_gemini_tts_bytes",
1412
+ "chat_state",
1413
+ "GLOBAL_QUOTA",
1414
+ "_is_owner",
1415
+ "_is_privileged_in_chat",
1416
+ "_mark_quota_exhausted",
1417
+ "_record_quota_usage",
1418
+ "PICK_TTL_SEC",
1419
+ "_pending_picks",
1420
+ "_purge_expired_picks",
1421
+ "_enforce_pending_picks_cap",
1422
  ]
1423
 
1424
  # Троттлинг для уведомления владельца о полном исчерпании квоты Gemini (см.
 
1749
 
1750
  # команды бота
1751
 
1752
+ # Команды/TTS/Draw/pick живут в lumen_commands.py (P2): здесь только реэкспорт
1753
+ # имён и регистрация хендлеров (декораторы @dp.* заменены явными register —
1754
+ # тот же порядок, то же поведение).
1755
+ from lumen_commands import (
1756
+ cmd_start,
1757
+ inline_draw,
1758
+ cmd_draw,
1759
+ _fish_audio_tts_bytes,
1760
+ _gemini_tts_bytes,
1761
+ inline_tts,
1762
+ cmd_tts,
1763
+ cmd_reset,
1764
+ cmd_lang,
1765
+ handle_lang_callback,
1766
+ cmd_logs,
1767
+ cmd_stats,
1768
+ _send_pick_question,
1769
+ handle_pick_callback,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1770
  )
1771
+ dp.message.register(cmd_start, Command("start"))
1772
+ dp.message.register(cmd_draw, Command("draw"))
1773
+ dp.message.register(cmd_tts, Command("tts"))
1774
+ dp.message.register(cmd_reset, Command("reset"))
1775
+ dp.message.register(cmd_lang, Command("lang"))
1776
+ dp.callback_query.register(handle_lang_callback)
1777
+ dp.message.register(cmd_logs, Command("logs"))
1778
+ dp.message.register(cmd_stats, Command("stats"))
1779
+ dp.callback_query.register(handle_pick_callback)
1780
 
1781
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1782
  # ─────────────────── автоматический выбор модели (роутер) ───────────────────
1783
  # Полностью заменяет ручной выбор через /model и /provider (обе команды удалены).
1784
  # Пользователь никогда явно не выбирает ни провайдера, ни модель — на КАЖДОЕ
 
1818
  # импортированы выше рядом с rate limit (P2), здесь используются напрямую.
1819
 
1820
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1821
 
1822
 
1823
 
lumen_commands.py ADDED
@@ -0,0 +1,638 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ lumen_commands.py — команды бота, TTS/Draw-пайплайны и кнопки-уточнения
3
+ (вынесено из bot.py, P2 аудита).
4
+
5
+ Связи с рантаймом bot.py — только через отложенный `import bot` внутри функций
6
+ (модульного цикла нет). bot.py реэкспортирует имена и регистрирует хендлеры
7
+ в диспетчере явными dp.*.register() — `bot.cmd_draw` и т.п. в тестах
8
+ не менялись.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import contextlib
14
+ import logging
15
+ import os
16
+ import secrets
17
+ import tempfile
18
+ import time
19
+ from types import SimpleNamespace
20
+ from typing import Any
21
+
22
+ from aiogram.enums import ChatType, ParseMode
23
+ from aiogram.types import (
24
+ BufferedInputFile,
25
+ CallbackQuery,
26
+ FSInputFile,
27
+ InlineKeyboardButton,
28
+ InlineKeyboardMarkup,
29
+ Message,
30
+ )
31
+
32
+ from lumen_images import (
33
+ POLLINATIONS_IMAGE_MODELS,
34
+ _pick_image_model,
35
+ )
36
+ from lumen_lang import SUPPORTED_LANGS, LANG_NAMES, normalize_lang, pick_texts, t as _lang_t
37
+ from lumen_limits import (
38
+ _purge_expired_picks,
39
+ _enforce_pending_picks_cap,
40
+ )
41
+ from lumen_tiktok import _communicate_process
42
+ from lumen_tts import (
43
+ pcm_to_wav,
44
+ _fish_audio_tts_bytes as _lumen_fish_audio_tts_bytes,
45
+ _gemini_tts_bytes as _lumen_gemini_tts_bytes,
46
+ )
47
+
48
+ log = logging.getLogger("bot")
49
+
50
+ async def cmd_start(message: Message) -> None:
51
+ import bot
52
+ await bot._tg_call(
53
+ message.reply,
54
+ bot._t(message.chat.id, "start_text"),
55
+ parse_mode=ParseMode.HTML,
56
+ )
57
+
58
+ async def inline_draw(message: Message, prompt: str) -> None:
59
+ import bot
60
+ status = await bot._tg_call(message.reply, bot._t(message.chat.id, "status_generating_image"))
61
+ try:
62
+ session = await bot._get_http_session()
63
+ # УБРАНО (аудит техдолга, 19 августа 2026): раньше основная модель бралась
64
+ # из ручного выбора пользователя (/imgmodel, команда удалена — см. README,
65
+ # "Автоматический выбор модели"). Теперь _pick_image_model сама подбирает
66
+ # модель по содержимому промпта на каждый вызов, без какого-либо состояния
67
+ # чата — тот же принцип, что уже применяется к тексту (_build_route).
68
+ primary_model = _pick_image_model(prompt)
69
+
70
+ # Фолбэк-цепочка: пробуем сначала подобранную модель,
71
+ # при ошибке переключаемся на следующие по порядку из POLLINATIONS_IMAGE_MODELS
72
+ all_model_ids = list(POLLINATIONS_IMAGE_MODELS.keys())
73
+ fallback_chain = [primary_model] + [m for m in all_model_ids if m != primary_model]
74
+
75
+ image_bytes = None
76
+ last_error = None
77
+ budget_exceeded = False
78
+ rate_limited = False
79
+ deadline = time.monotonic() + bot.DRAW_TOTAL_BUDGET_SEC
80
+
81
+ for attempt_model in fallback_chain:
82
+ if time.monotonic() > deadline:
83
+ budget_exceeded = True
84
+ log.warning(
85
+ '[draw] Overall time budget (%.0fs) exhausted before trying %s — stopping the fallback chain instead of trying the remaining models.',
86
+ bot.DRAW_TOTAL_BUDGET_SEC, attempt_model,
87
+ )
88
+ break
89
+ try:
90
+ # Статус нейтральный, без названий моделей: бот не раскрывает
91
+ # внутреннюю реализацию (см. ИДЕНТИЧНОСТЬ в system_prompt.py).
92
+ # На первой попытке статус и так "Генерирую изображение" — не трогаем.
93
+ if attempt_model != primary_model:
94
+ await bot._edit_message_quietly(status, bot._t(message.chat.id, "status_taking_longer"))
95
+ image_bytes = await bot._pollinations_text_to_image(session, attempt_model, prompt)
96
+ break
97
+ except Exception as exc:
98
+ last_error = exc
99
+ txt = bot._error_text(exc).lower()
100
+ # 429 — перегрузка ВСЕГО сервиса (см. прод 17.09.2026: все 5 моделей
101
+ # вернули 429 подряд), а не одной модели — гонять остаток цепочки
102
+ # бессмысленно, сразу говорим пользователю подождать.
103
+ if "429" in txt or "rate" in txt or "too many" in txt:
104
+ log.warning("[draw] Service rate-limited (429) on model %s — stopping the fallback chain.", attempt_model)
105
+ rate_limited = True
106
+ break
107
+ # Пробуем следующую только при сетевых/серверных ошибках
108
+ if any(kw in txt for kw in ("cannot connect", "ssl:", "no address", "503", "502", "timeout", "host")):
109
+ log.warning("[draw] Model %s failed (%s), trying next fallback", attempt_model, type(exc).__name__)
110
+ continue
111
+ # При других ошибках (400, 404 и т.д.) тоже пробуем следующую
112
+ log.warning("[draw] Model %s error: %s, trying next", attempt_model, exc)
113
+ continue
114
+
115
+ if image_bytes:
116
+ await bot._delete_message_quietly(status)
117
+ await bot.bot.send_photo(
118
+ chat_id=message.chat.id,
119
+ photo=BufferedInputFile(image_bytes, filename="generated.jpg"),
120
+ reply_to_message_id=message.message_id,
121
+ )
122
+ else:
123
+ if rate_limited:
124
+ raise RuntimeError("image generation service overloaded with requests") from last_error
125
+ if budget_exceeded:
126
+ raise RuntimeError("image generation total time budget exceeded") from last_error
127
+ raise last_error or RuntimeError("all image generation models unavailable")
128
+
129
+ except Exception as exc:
130
+ log.exception("Pollinations image generation failed:")
131
+ txt = bot._error_text(exc).strip()
132
+ cid = message.chat.id
133
+ if any(kw in txt.lower() for kw in ("cannot connect", "ssl:", "no address", "connection", "timeout", "host")):
134
+ user_err = bot._t(cid, "draw_err_unavailable")
135
+ elif "overloaded" in txt.lower():
136
+ user_err = bot._t(cid, "draw_err_overloaded")
137
+ elif "all image generation" in txt.lower():
138
+ user_err = bot._t(cid, "draw_err_gone")
139
+ elif "time budget" in txt.lower():
140
+ user_err = bot._t(cid, "draw_err_budget")
141
+ else:
142
+ # Сырой текст ошибки провайдера пользователю не показываем (см. log.exception
143
+ # выше) — та же логика, что и в остальных обработчиках ошибок бота.
144
+ user_err = bot._t(cid, "draw_err_generic")
145
+ await bot._edit_message_quietly(status, user_err)
146
+
147
+
148
+ async def cmd_draw(message: Message) -> None:
149
+ import bot
150
+ prompt = message.text.partition(" ")[2].strip() if message.text else ""
151
+ if not prompt:
152
+ await bot._safe_reply(message, bot._t(message.chat.id, "draw_empty"))
153
+ return
154
+ if await bot._reject_rate_limited_message(message):
155
+ return
156
+ await bot.inline_draw(message, prompt)
157
+
158
+
159
+
160
+ async def _fish_audio_tts_bytes(text: str) -> bytes | None:
161
+ import bot
162
+ session = await bot._get_http_session()
163
+ return await _lumen_fish_audio_tts_bytes(
164
+ session, text,
165
+ api_key=bot.OPENROUTER_API_KEY, http_referer=bot.OPENROUTER_HTTP_REFERER,
166
+ title=bot.OPENROUTER_TITLE, base_url=bot.OPENROUTER_BASE_URL,
167
+ model_id=bot.FISH_AUDIO_TTS_MODEL, request_timeout_sec=bot.ROUTE_MODEL_TIMEOUT_SEC,
168
+ )
169
+
170
+
171
+ async def _gemini_tts_bytes(text: str) -> tuple[bytes, str, str]:
172
+ import bot
173
+ def _is_rate_limit(e: Exception) -> bool:
174
+ err_txt = bot._error_text(e).strip() or e.__class__.__name__
175
+ return bot._classify_model_error(bot._error_status(e, err_txt), err_txt) == "rate_limit"
176
+
177
+ # Пишем в тот же провайдер "gemini" — /stats уже показывает GLOBAL_QUOTA["gemini"]
178
+ # по всем моделям отсортированным по расходу, TTS-модели появляются там же.
179
+ return await _lumen_gemini_tts_bytes(
180
+ bot.client, text, tts_models=bot.GEMINI_TTS_MODELS,
181
+ is_rate_limit_error=_is_rate_limit,
182
+ on_model_exhausted=lambda mname: bot._mark_quota_exhausted("gemini", mname),
183
+ on_model_success=lambda mname: bot._record_quota_usage("gemini", mname),
184
+ )
185
+
186
+
187
+ async def inline_tts(message: Message, text: str) -> None:
188
+ import bot
189
+ if len(text) > bot.TTS_MAX_CHARS:
190
+ await bot._safe_reply(
191
+ message,
192
+ bot._t(message.chat.id, "tts_too_long", limit=bot.TTS_MAX_CHARS, length=len(text)),
193
+ )
194
+ return
195
+ status = await bot._tg_call(message.reply, bot._t(message.chat.id, "status_voicing"))
196
+ try:
197
+ # FISH_AUDIO_ENABLED=False (аудит моделей, 17.09.2026 — зеркало снято с
198
+ # бесплатного каталога OpenRouter): пропускаем заведомо мёртвую первую
199
+ # попытку и идём сразу на Gemini TTS. Ветка fish оставлена, не удалена —
200
+ # см. комментарий у флага в lumen_router_config.py.
201
+ fish_bytes = await bot._fish_audio_tts_bytes(text) if bot.FISH_AUDIO_ENABLED else None
202
+ if fish_bytes is not None:
203
+ pcm_bytes, mime_type, used_tts_model = fish_bytes, "audio/mp3", bot.FISH_AUDIO_TTS_MODEL
204
+ bot._record_quota_usage("openrouter", bot.FISH_AUDIO_TTS_MODEL)
205
+ else:
206
+ pcm_bytes, mime_type, used_tts_model = await bot._gemini_tts_bytes(text)
207
+ log.info('[tts] Synthesis received from %s, mime_type=%s, bytes=%d', used_tts_model, mime_type, len(pcm_bytes))
208
+
209
+ # определяем формат исходника
210
+ 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'):
211
+ src_ext = ".mp3"
212
+ raw_audio = pcm_bytes
213
+ elif pcm_bytes.startswith(b'RIFF') or "wav" in mime_type:
214
+ src_ext = ".wav"
215
+ raw_audio = pcm_bytes
216
+ else:
217
+ # сырой PCM сначала оборачиваем в WAV
218
+ src_ext = ".wav"
219
+ raw_audio = pcm_to_wav(pcm_bytes, sample_rate=24000)
220
+
221
+ # конвертируем в OGG/Opus через ffmpeg — send_voice в Telegram без этого
222
+ # покажет длительность 0:00
223
+ final_audio = raw_audio
224
+ final_filename = "speech.ogg"
225
+ voice_duration = 0
226
+ try:
227
+ with tempfile.TemporaryDirectory() as tdir:
228
+ src_path = os.path.join(tdir, f"tts_src{src_ext}")
229
+ dst_path = os.path.join(tdir, "tts_out.ogg")
230
+ with open(src_path, "wb") as fh:
231
+ fh.write(raw_audio)
232
+ proc = await asyncio.create_subprocess_exec(
233
+ "ffmpeg", "-y", "-i", src_path,
234
+ "-c:a", "libopus", "-b:a", "64k", "-vbr", "on",
235
+ dst_path,
236
+ stdout=asyncio.subprocess.DEVNULL,
237
+ stderr=asyncio.subprocess.DEVNULL,
238
+ )
239
+ await _communicate_process(proc, timeout=30)
240
+ if os.path.exists(dst_path) and os.path.getsize(dst_path) > 0:
241
+ with open(dst_path, "rb") as fh:
242
+ final_audio = fh.read()
243
+ # ffprobe — получаем длительность для Telegram (без неё показывает 0:00)
244
+ try:
245
+ probe = await asyncio.create_subprocess_exec(
246
+ "ffprobe", "-v", "error",
247
+ "-show_entries", "format=duration",
248
+ "-of", "default=noprint_wrappers=1:nokey=1",
249
+ dst_path,
250
+ stdout=asyncio.subprocess.PIPE,
251
+ stderr=asyncio.subprocess.DEVNULL,
252
+ )
253
+ probe_out, _ = await _communicate_process(probe, timeout=10)
254
+ raw_dur = probe_out.decode().strip()
255
+ voice_duration = max(1, round(float(raw_dur))) if raw_dur else 0
256
+ except Exception as probe_exc:
257
+ log.warning("[tts] ffprobe failed: %s", probe_exc)
258
+ log.info("[tts] OGG/Opus: %d bytes, duration: %ds", len(final_audio), voice_duration)
259
+ else:
260
+ log.warning("[tts] ffmpeg OGG conversion failed, falling back to raw audio")
261
+ final_filename = f"speech{src_ext}"
262
+ except Exception as conv_exc:
263
+ log.warning("[tts] ffmpeg conversion error: %s", conv_exc)
264
+ final_filename = f"speech{src_ext}"
265
+
266
+ await bot._delete_message_quietly(status)
267
+ await bot.bot.send_voice(
268
+ chat_id=message.chat.id,
269
+ voice=BufferedInputFile(final_audio, filename=final_filename),
270
+ duration=voice_duration if voice_duration > 0 else None,
271
+ reply_to_message_id=message.message_id
272
+ )
273
+ except Exception as exc:
274
+ log.exception("TTS synthesis failed:")
275
+ # Сырой текст ошибки пользователю не показываем — он может содержать
276
+ # реальные ID моделей ("gemini-3.1-flash-tts-preview" и т.п.) или другие
277
+ # служебные детали. Используем ту же классификацию, что и для чата.
278
+ txt = bot._error_text(exc).strip() or exc.__class__.__name__
279
+ kind = bot._classify_model_error(bot._error_status(exc, txt), txt)
280
+ cid = message.chat.id
281
+ if kind == "rate_limit":
282
+ user_err = bot._t(cid, "tts_err_exhausted")
283
+ else:
284
+ user_err = bot._t(cid, "tts_err_generic")
285
+ await bot._edit_message_quietly(status, user_err)
286
+
287
+ async def cmd_tts(message: Message) -> None:
288
+ import bot
289
+ text = message.text.partition(" ")[2].strip() if message.text else ""
290
+ if not text:
291
+ await bot._safe_reply(message, bot._t(message.chat.id, "tts_empty"))
292
+ return
293
+ if await bot._reject_rate_limited_message(message):
294
+ return
295
+ await bot.inline_tts(message, text)
296
+
297
+
298
+ async def cmd_reset(message: Message) -> None:
299
+ """Сбрасывает историю диалога в текущем чате. Намеренно скрыта: не добавлена
300
+ в setMyCommands и не упомянута в /start — чтобы не загромождать меню команд
301
+ (как и /logs). Доступ: в личных сообщениях — всем (это история только одного
302
+ человека), в группах — только администратору/создателю группы или владельцу
303
+ бота (сброс общей истории всей группы — не рядовое действие)."""
304
+ import bot
305
+ requester_id = message.from_user.id if message.from_user else None
306
+ if not await bot._is_privileged_in_chat(message.chat.type, message.chat.id, requester_id):
307
+ await bot._tg_call(
308
+ message.reply,
309
+ bot._t(message.chat.id, "reset_deny")
310
+ )
311
+ return
312
+ state = bot.get_state(message.chat.id)
313
+ state["history"] = []
314
+ state["ctx"].clear()
315
+ bot.mark_state_dirty(message.chat.id)
316
+ await bot._tg_call(
317
+ message.reply,
318
+ bot._t(message.chat.id, "reset_done")
319
+ )
320
+
321
+ # ── Язык бота (/lang) ──
322
+ # Переключает язык СИСТЕМНЫХ сообщений бота в этом чате (/start, подсказки,
323
+ # ошибки, статусы — всё, что бот пишет сам). Ответы ИИ не трогает: модель
324
+ # отвечает на языке собеседника (см. RESPONSE LANGUAGE в system_prompt.py).
325
+ # Права — как у /reset (см. _is_privileged_in_chat): в личке меняет кто
326
+ # угодно, в группе — админ/создатель группы или владелец бота. Смотреть меню
327
+ # может любой; непривилегированный тап вежливо отклоняется. Кнопки без флагов
328
+ # (флаг ≠ язык) и без эмодзи, текущий язык помечен текстовой галочкой ✓.
329
+ # Языки в меню — по алфавиту кода (SUPPORTED_LANGS в lumen_lang.py).
330
+ async def cmd_lang(message: Message) -> None:
331
+ import bot
332
+ lang = bot._chat_lang(message.chat.id)
333
+ rows = []
334
+ codes = list(SUPPORTED_LANGS)
335
+ for i in range(0, len(codes), 2):
336
+ row = []
337
+ for code in codes[i:i + 2]:
338
+ mark = " ✓" if code == lang else ""
339
+ row.append(InlineKeyboardButton(
340
+ text=f"{LANG_NAMES[code]}{mark}",
341
+ callback_data=f"lang:{code}",
342
+ ))
343
+ rows.append(row)
344
+ await bot._tg_call(
345
+ message.reply,
346
+ bot._t(message.chat.id, "lang_title"),
347
+ reply_markup=InlineKeyboardMarkup(inline_keyboard=rows),
348
+ )
349
+
350
+
351
+ async def handle_lang_callback(query: CallbackQuery) -> None:
352
+ """Нажатие кнопки языка: проверка прав, сохранение, подтверждение на новом
353
+ языке. callback_data — "lang:<код>", короткие коды влезают в лимит 64 байт
354
+ с запасом."""
355
+ import bot
356
+ data = query.data or ""
357
+ if not data.startswith("lang:"):
358
+ return
359
+ parts = data.split(":")
360
+ if len(parts) != 2:
361
+ with contextlib.suppress(Exception):
362
+ await query.answer()
363
+ return
364
+ code = normalize_lang(parts[1])
365
+ question_msg = query.message
366
+ if question_msg is None or question_msg.chat is None:
367
+ with contextlib.suppress(Exception):
368
+ await query.answer()
369
+ return
370
+ chat = question_msg.chat
371
+ requester_id = query.from_user.id if query.from_user else None
372
+ if not await bot._is_privileged_in_chat(chat.type, chat.id, requester_id):
373
+ with contextlib.suppress(Exception):
374
+ await query.answer(bot._t(chat.id, "lang_deny"), show_alert=True)
375
+ return
376
+ state = bot.get_state(chat.id)
377
+ state["lang"] = code
378
+ bot.mark_state_dirty(chat.id)
379
+ with contextlib.suppress(Exception):
380
+ await query.answer()
381
+ with contextlib.suppress(Exception):
382
+ await bot._tg_call(
383
+ question_msg.edit_text,
384
+ _lang_t(code, "lang_done"),
385
+ parse_mode=None, call_timeout=15.0,
386
+ reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
387
+ )
388
+
389
+ # выгрузка логов (только для владельца)
390
+
391
+ async def cmd_logs(message: Message) -> None:
392
+ import bot
393
+ is_owner = bot._is_owner(message.from_user.id if message.from_user else None)
394
+
395
+ if not is_owner:
396
+ await bot._tg_call(message.reply, bot._t(message.chat.id, "logs_deny"))
397
+ return
398
+
399
+ if message.chat.type != ChatType.PRIVATE:
400
+ # Найдено при код-ревью: результат команды видят ВСЕ участники чата, в
401
+ # котором она вызвана, а не только владелец — файл логов содержит реальные
402
+ # технические детали (ID моделей и т.п.), которые не должны светиться в
403
+ # групповых чатах.
404
+ await bot._tg_call(message.reply, bot._t(message.chat.id, "logs_group_only"))
405
+ return
406
+
407
+ # сбрасываем буфер логов на диск — НАЙДЕНО ПРИ АУДИТЕ ЛОГИРОВАНИЯ: после
408
+ # перехода на QueueHandler/QueueListener у root-логгера остался только сам
409
+ # QueueHandler (его flush() — no-op), реальные file_handler/console_handler
410
+ # живут внутри _LOG_LISTENER, а не на root — цикл по logging.getLogger().handlers
411
+ # ничего не флашил уже с момента этой миграции.
412
+ try:
413
+ if bot._LOG_LISTENER is not None:
414
+ for handler in bot._LOG_LISTENER.handlers:
415
+ handler.flush()
416
+ except Exception:
417
+ pass
418
+
419
+ try:
420
+ log_content = ""
421
+ if bot.LOG_FILE_PATH.exists():
422
+ with open(bot.LOG_FILE_PATH, "r", encoding="utf-8", errors="ignore") as f:
423
+ log_content = f.read()
424
+
425
+ if not log_content or len(log_content.strip()) == 0:
426
+ await bot._tg_call(message.reply, bot._t(message.chat.id, "logs_empty"))
427
+ return
428
+
429
+ # вычищаем токены из логов перед отправкой — единый список, см. _redactable_secrets
430
+ for secret in bot._redactable_secrets():
431
+ log_content = log_content.replace(secret, "<REDACTED>")
432
+
433
+ # пишем во временный файл, чтобы не ловить блокировку на живом логе
434
+ tmp_dir = tempfile.gettempdir()
435
+ temp_log_path = os.path.join(tmp_dir, "logs.txt")
436
+ with open(temp_log_path, "w", encoding="utf-8", errors="ignore") as f:
437
+ f.write(log_content)
438
+
439
+ await bot._tg_call(message.reply_document, FSInputFile(temp_log_path, filename="logs.txt"))
440
+
441
+ try:
442
+ os.unlink(temp_log_path)
443
+ except Exception:
444
+ pass
445
+ except Exception as exc:
446
+ log.exception("Error extracting or sending logs:")
447
+ await bot._tg_call(message.reply, bot._t(message.chat.id, "logs_send_error", error=exc))
448
+
449
+ async def cmd_stats(message: Message) -> None:
450
+ """Глобальная статистика бота. Скрыта (не в setMyCommands, не в /start) и
451
+ доступна только владельцу — тот же принцип доступа, что и у /logs, т.к.
452
+ показывает данные по всем чатам, а не только текущему."""
453
+ import bot
454
+ is_owner = bot._is_owner(message.from_user.id if message.from_user else None)
455
+ if not is_owner:
456
+ await bot._tg_call(message.reply, bot._t(message.chat.id, "stats_deny"))
457
+ return
458
+
459
+ if message.chat.type != ChatType.PRIVATE:
460
+ # См. аналогичную проверку в /logs — статистика содержит реальные ID
461
+ # моделей Gemini/OpenRouter, не должна светиться в групповых чатах.
462
+ await bot._tg_call(message.reply, bot._t(message.chat.id, "stats_group_only"))
463
+ return
464
+
465
+ # Счётчики квоты — по датам America/Los_Angeles (полночь Google для RPD-лимитов),
466
+ # см. _reset_quota_if_new_day. Проверяем прямо перед отрисовкой /stats, чтобы
467
+ # владелец не увидел вчерашние числа, даже если часовой фоновый тик ещё не
468
+ # успел сработать (реальный найденный баг — см. историю: used-счётчики копились
469
+ # НАВСЕГДА через рестарты и не имели отношения к аптайму процесса).
470
+ bot._reset_quota_if_new_day()
471
+
472
+ total_chats = len(bot.chat_state)
473
+ uptime_sec = int(time.monotonic() - bot._PROCESS_START_MONOTONIC)
474
+ uptime_str = f"{uptime_sec // 3600}ч {(uptime_sec % 3600) // 60}м"
475
+
476
+ gemini_quota = bot.GLOBAL_QUOTA.get("gemini", {})
477
+ _stats_lang = bot._chat_lang(message.chat.id)
478
+ _limitTag = _lang_t(_stats_lang, "stats_limit_used")
479
+ _noData = _lang_t(_stats_lang, "stats_no_data")
480
+ gemini_lines = [
481
+ f" • {mid}: {e.get('used', 0)}{_limitTag if e.get('exhausted_at') else ''}"
482
+ for mid, e in sorted(gemini_quota.items(), key=lambda kv: -(kv[1].get("used") or 0))
483
+ ]
484
+ gemini_text = "\n".join(gemini_lines) or _noData
485
+
486
+ # ИСПРАВЛЕНО (найдено при калибровке 25 июля 2026): раньше здесь была только
487
+ # ОДНА суммарная цифра запросов OpenRouter плюс денежный $-баланс аккаунта —
488
+ # для отладки роутинга и выявления "тупящих" моделей это бесполезно: не видно,
489
+ # КАКАЯ именно модель отвечала и сколько раз. Теперь — та же разбивка по
490
+ # моделям, что уже была у Gemini, отсортированная по расходу. $-баланс убран
491
+ # целиком: все модели в списке — :free, платный баланс тут ни на что не влияет
492
+ # и только замусоривал вывод.
493
+ or_quota = bot.GLOBAL_QUOTA.get("openrouter", {})
494
+ or_lines = [
495
+ f" • {mid}: {e.get('used', 0)}{_limitTag if e.get('exhausted_at') else ''}"
496
+ for mid, e in sorted(or_quota.items(), key=lambda kv: -(kv[1].get("used") or 0))
497
+ ]
498
+ or_text = "\n".join(or_lines) or _noData
499
+
500
+ # Видимость состояния "выключателя" Telegram-прокси прямо из Telegram, а не
501
+ # только по логам контейнера — иначе деградацию прокси можно было заметить
502
+ # только копаясь в логах HF Spaces (см. код-ревью, suggestion #1). Текст
503
+ # теперь собирает сам _tg_proxy_breaker (см. _TelegramProxyCircuitBreaker) —
504
+ # раньше эта команда лезла в четыре module-level globals напрямую.
505
+ proxy_line = bot._tg_proxy_breaker.status_text()
506
+
507
+ quota_day = bot.GLOBAL_QUOTA.get("quota_day") or "—"
508
+
509
+ text = (
510
+ f"<b>Статистика Lumen</b>\n\n"
511
+ f"Активных чатов: {total_chats}\n"
512
+ f"Аптайм процесса: {uptime_str}\n"
513
+ f"Счётчики квоты за сутки: {quota_day} (America/Los_Angeles, сбрасываются автоматически)\n\n"
514
+ f"<b>Gemini — запросов по моделям:</b>\n{gemini_text}\n\n"
515
+ f"<b>OpenRouter — запросов по моделям:</b>\n{or_text}"
516
+ f"{proxy_line}"
517
+ )
518
+ await bot._tg_call(message.reply, text, parse_mode=ParseMode.HTML)
519
+
520
+
521
+ async def _send_pick_question(message: Message, scenario: str, original_text: str) -> None:
522
+ """Отправляет уточняющий вопрос с кнопками и запоминает контекст выбора.
523
+ token в callback_data короткий (лимит Telegram — 64 байта на всю строку)."""
524
+ import bot
525
+ _purge_expired_picks()
526
+ _enforce_pending_picks_cap()
527
+ token = secrets.token_hex(4)
528
+ lang = bot._chat_lang(message.chat.id)
529
+ bot._pending_picks[token] = {
530
+ "chat_id": message.chat.id,
531
+ "user_id": message.from_user.id if message.from_user else None,
532
+ "scenario": scenario,
533
+ "original": original_text,
534
+ "expires": time.monotonic() + bot.PICK_TTL_SEC,
535
+ "lang": lang,
536
+ }
537
+ question, options, _tpl = pick_texts(lang, scenario)
538
+ keyboard = InlineKeyboardMarkup(inline_keyboard=[
539
+ [InlineKeyboardButton(text=option, callback_data=f"pick:{token}:{idx}")]
540
+ for idx, option in enumerate(options)
541
+ ])
542
+ await bot._tg_call(
543
+ message.reply,
544
+ question + bot._t(message.chat.id, "pick_suffix"),
545
+ reply_markup=keyboard,
546
+ )
547
+
548
+
549
+ async def handle_pick_callback(query: CallbackQuery) -> None:
550
+ """Обработчик нажатий кнопок-уточнений. Чужие кнопки (другой пользователь
551
+ в группе) и протухшие/перезапущенные записи отклоняются вежливо, без
552
+ обработки. Выбор дописывается к исходному запросу по шаблону сценария и
553
+ уходит обычным путём через _handle_message_core — дальше роутер, стриминг
554
+ и история работают как для текстового сообщения."""
555
+ import bot
556
+ data = query.data or ""
557
+ if not data.startswith("pick:"):
558
+ return
559
+ parts = data.split(":")
560
+ if len(parts) != 3:
561
+ with contextlib.suppress(Exception):
562
+ await query.answer()
563
+ return
564
+ _, token, idx_raw = parts
565
+ try:
566
+ idx = int(idx_raw)
567
+ except (TypeError, ValueError):
568
+ with contextlib.suppress(Exception):
569
+ await query.answer()
570
+ return
571
+ # Pop сразу (а не после проверок): повторный тап по тем же кнопкам не
572
+ # должен порождать второй ответ. Чужая кнопка при этом "сгорает" — цена
573
+ # приемлема: владелец переспросит текстом.
574
+ rec = bot._pending_picks.pop(token, None)
575
+ # Язык для служебных реплик: из записи (если есть), иначе из чата кноп��и.
576
+ _qchat = query.message.chat.id if query.message and query.message.chat else None
577
+ rec_lang = (rec or {}).get("lang") or bot._chat_lang(_qchat)
578
+ if rec is None or rec["expires"] < time.monotonic():
579
+ with contextlib.suppress(Exception):
580
+ await query.answer(_lang_t(rec_lang, "pick_expired"), show_alert=False)
581
+ return
582
+ _q, options, tpl = pick_texts(rec_lang, rec.get("scenario", ""))
583
+ if not 0 <= idx < len(options):
584
+ with contextlib.suppress(Exception):
585
+ await query.answer()
586
+ return
587
+ if rec["user_id"] is not None and query.from_user is not None and query.from_user.id != rec["user_id"]:
588
+ with contextlib.suppress(Exception):
589
+ await query.answer(_lang_t(rec_lang, "pick_not_yours"), show_alert=True)
590
+ return
591
+ choice = options[idx]
592
+ with contextlib.suppress(Exception):
593
+ await query.answer()
594
+ question_msg = query.message
595
+ if question_msg is None:
596
+ return
597
+ with contextlib.suppress(Exception):
598
+ # Клавиатуру снимаем пустой разметкой, иначе кнопки останутся висеть
599
+ # под сообщением (повторный тап при этом всё равно упрётся в pop выше).
600
+ await bot._tg_call(
601
+ question_msg.edit_text,
602
+ f"{_q}\n\n{_lang_t(rec_lang, 'pick_choice', choice=choice)}",
603
+ parse_mode=None, call_timeout=15.0,
604
+ reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
605
+ )
606
+ augmented = tpl.format(original=rec["original"], choice=choice)
607
+ chat = question_msg.chat
608
+ from_user = query.from_user
609
+
610
+ async def _pick_reply(text: str, **kwargs: Any) -> Any:
611
+ return await bot._tg_call(
612
+ bot.bot.send_message, chat_id=chat.id, text=text,
613
+ reply_to_message_id=getattr(question_msg, "message_id", None),
614
+ **kwargs,
615
+ )
616
+
617
+ ns = SimpleNamespace(
618
+ text=augmented, caption=None, chat=chat, from_user=from_user,
619
+ sender_chat=None, reply_to_message=None,
620
+ message_id=getattr(question_msg, "message_id", None),
621
+ reply=_pick_reply,
622
+ )
623
+ # Флаг против зацикливания: дополненный текст всё ещё матчится детектором
624
+ # ("посоветуй фильм (жанр: ...)"), без флага ушёл бы снова в кнопки.
625
+ ns._pick_resolved = True
626
+ lock = bot.get_chat_lock(chat.id)
627
+ try:
628
+ await asyncio.wait_for(lock.acquire(), timeout=10.0)
629
+ except asyncio.TimeoutError:
630
+ log.warning("[pick] Timeout waiting for lock on chat %s", chat.id)
631
+ with contextlib.suppress(Exception):
632
+ await query.answer(_lang_t(rec_lang, "pick_lock_busy"), show_alert=True)
633
+ return
634
+ try:
635
+ await bot._handle_message_core(ns)
636
+ finally:
637
+ with contextlib.suppress(Exception):
638
+ lock.release()