Lumen / lumen_rich.py
SilverElixir
Isolate tests from live provider keys, requeue cancelled state flushes and trim redundant latency in search, draw, rich conversion and TikTok metadata
da6a2d2
Raw History Blame Contribute Delete
8.97 kB
"""
lumen_rich.py — отправка сообщений: гости, Rich Messages, чанкинг.
"""
from __future__ import annotations
import contextlib
import hashlib
import logging
from typing import Any
from aiogram import Bot
from aiogram.enums import ChatType, ParseMode
from aiogram.types import (
InlineQueryResultArticle,
InputRichMessage,
InputTextMessageContent,
Message,
ReplyParameters,
)
from lumen_formatting import (
_md_to_html,
_md_to_rich_html,
_split_text_chunks,
_strip_markdown,
_truncate_html_to_fit,
)
log = logging.getLogger("bot")
def is_guest_message(message: Message | dict) -> bool:
if isinstance(message, dict):
return bool(message.get("guest_query_id"))
return bool(getattr(message, "guest_query_id", None))
async def _answer_guest_text(message: Message, text: str) -> None:
import bot
qid = getattr(message, "guest_query_id", None)
if not qid:
return
payload = _truncate_html_to_fit(text, bot.TG_MAX_LEN)
res_id = hashlib.sha1(payload.encode("utf-8", errors="ignore")).hexdigest()[:32]
res_art = InlineQueryResultArticle(
id=res_id, title=bot._t(message.chat.id, "inline_answer_title"),
input_message_content=InputTextMessageContent(message_text=payload, parse_mode="HTML"),
)
try:
await bot.telegram_api_call("answerGuestQuery", {
"guest_query_id": str(qid),
"result": res_art.model_dump(exclude_none=True),
}, request_timeout=10)
except Exception as exc:
log.warning("[guest] Failed answering guest: %s", exc)
def _is_real_telegram_message(res: Any) -> bool:
"""Только реальный Message (int message_id): фейки тестов тихо уходят на legacy."""
return isinstance(res, Message)
def _answer_sender(message: Message):
"""Куда отправлять ответ: в личке — обычное сообщение без цитаты-реплая,
как в Claude; в группе — reply для контекста. Тестовые фейки без реального
aiogram Bot получают reply (прежнее поведение)."""
import bot
chat = getattr(message, "chat", None)
if (getattr(chat, "type", None) == ChatType.PRIVATE
and isinstance(getattr(bot, "bot", None), Bot)):
async def _send(text: str, **kwargs: Any):
# Текст — отдельным kwarg: partial(chat_id=...) + позиционный текст
# давал "multiple values for argument 'chat_id'" и убивал стрим
# с ответами в личке (прод 28.10.2026).
return await bot.bot.send_message(chat_id=chat.id, text=text, **kwargs)
return _send
return message.reply
async def _try_send_rich(message: Message, rich_html: str, *, is_first_chunk: bool, **kwargs: Any) -> Any:
"""Рич-отправка; при неуспехе — None и legacy. Трединг только первого чанка, иначе каждый кусок пиликает отдельно."""
import bot
if not bot.RICH_MESSAGES_ENABLED:
return None
sender = getattr(bot.bot, "send_rich_message", None)
if sender is None or message is None:
return None
kwargs.pop("parse_mode", None)
reply_parameters = kwargs.pop("reply_parameters", None)
is_private = getattr(getattr(message, "chat", None), "type", None) == ChatType.PRIVATE
if (reply_parameters is None and is_first_chunk and not is_private
and isinstance(getattr(message, "message_id", None), int)):
reply_parameters = ReplyParameters(message_id=message.message_id)
call_timeout = kwargs.pop("call_timeout", bot.TELEGRAM_REQUEST_TIMEOUT)
res = await bot._tg_call(
sender, chat_id=message.chat.id, rich_message=InputRichMessage(html=rich_html),
reply_parameters=reply_parameters, call_timeout=call_timeout, **kwargs,
)
return res if bot._is_real_telegram_message(res) else None
async def _try_edit_rich(msg: Message | None, rich_html: str, **kwargs: Any) -> bool:
"""Рич-правка; True только при успехе, иначе legacy. Фейки без int id отсекаются."""
import bot
if not bot.RICH_MESSAGES_ENABLED or msg is None:
return False
editor = getattr(bot.bot, "edit_message_text", None)
chat = getattr(msg, "chat", None)
chat_id = getattr(chat, "id", None)
message_id = getattr(msg, "message_id", None)
if editor is None or not isinstance(chat_id, int) or not isinstance(message_id, int):
return False
kwargs.pop("parse_mode", None)
call_timeout = kwargs.pop("call_timeout", 15.0)
res = await bot._tg_call(
editor, text=None, rich_message=InputRichMessage(html=rich_html),
chat_id=chat_id, message_id=message_id, call_timeout=call_timeout, **kwargs,
)
return bot._is_real_telegram_message(res)
async def _send_text(message: Message, text: str, parse_html: bool = True, **kwargs: Any) -> None:
import bot
if bot.is_guest_message(message):
await bot._answer_guest_text(message, text)
return
chunks = _split_text_chunks(text, bot.TG_MAX_LEN)
sender = _answer_sender(message)
for i, chunk in enumerate(chunks):
chunk_kwargs = kwargs if i == len(chunks) - 1 else {k: v for k, v in kwargs.items() if k != "reply_markup"}
if i > 0:
# Пауза между кусками — против флуд-контроля за серию подряд.
await bot._typing_sleep(bot.STREAM_TYPING_TICK_SEC)
if parse_html and bot.RICH_MESSAGES_ENABLED:
# Сначала рич (таблицы/заголовки/математика), при любом неуспехе —
# обычный HTML-путь ниже. Reply threading — только у первого чанка.
# При выключенном риче не тратим время на конвертацию (0.3-0.7 мс/чанк).
rich_res = await bot._try_send_rich(message, _md_to_rich_html(chunk), is_first_chunk=(i == 0), **chunk_kwargs)
if rich_res is not None:
continue
if i == 0:
res = await bot._tg_call(
sender, _md_to_html(chunk) if parse_html else chunk,
call_timeout=bot.TELEGRAM_REQUEST_TIMEOUT,
parse_mode=ParseMode.HTML if parse_html else None,
**chunk_kwargs,
)
if res is None and parse_html:
res = await bot._tg_call(
sender, _strip_markdown(chunk),
call_timeout=bot.TELEGRAM_REQUEST_TIMEOUT,
parse_mode=None,
**chunk_kwargs,
)
else:
res = await bot._tg_call(
bot.bot.send_message,
chat_id=message.chat.id, text=_md_to_html(chunk) if parse_html else chunk,
call_timeout=bot.TELEGRAM_REQUEST_TIMEOUT,
parse_mode=ParseMode.HTML if parse_html else None,
**chunk_kwargs,
)
if res is None and parse_html:
await bot._tg_call(
bot.bot.send_message,
chat_id=message.chat.id, text=_strip_markdown(chunk),
call_timeout=bot.TELEGRAM_REQUEST_TIMEOUT,
parse_mode=None,
**chunk_kwargs,
)
async def _safe_reply(message: Message, text: str, parse_html: bool = True, **kwargs: Any) -> None:
import bot
await bot._send_text(message, text, parse_html=parse_html, **kwargs)
async def _delete_message_quietly(msg: Message | None) -> None:
if msg is None:
return
with contextlib.suppress(Exception):
await msg.delete()
async def _edit_message_quietly(msg: Message | None, text: str, **kwargs: Any) -> bool:
import bot
if msg is None:
return False
try:
# Порядок: рич → HTML → голый текст; таблицы видны только через рич.
if bot.RICH_MESSAGES_ENABLED and await bot._try_edit_rich(msg, _md_to_rich_html(text), **kwargs):
return True
kwargs.setdefault("parse_mode", ParseMode.HTML)
res = await bot._tg_call(msg.edit_text, _md_to_html(text), **kwargs)
if res is not None:
return True
kwargs["parse_mode"] = None
# Последний рубеж — голый текст БЕЗ markdown-синтаксиса (см.
# _strip_markdown): сырые `**` в чате хуже потери жирности.
res = await bot._tg_call(msg.edit_text, _strip_markdown(text), **kwargs)
return res is not None
except Exception:
return False