Spaces:
Running
Running
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- bot.py +47 -604
- 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 |
-
|
| 1742 |
-
|
| 1743 |
-
|
| 1744 |
-
|
| 1745 |
-
|
| 1746 |
-
|
| 1747 |
-
|
| 1748 |
-
|
| 1749 |
-
|
| 1750 |
-
|
| 1751 |
-
|
| 1752 |
-
|
| 1753 |
-
|
| 1754 |
-
|
| 1755 |
-
|
| 1756 |
-
|
| 1757 |
-
|
| 1758 |
-
|
| 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()
|