From 373844a7d81b0be578324c9b941801ba5baed02e Mon Sep 17 00:00:00 2001 From: Verum Date: Mon, 23 Feb 2026 14:11:57 +0700 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20html-=D1=80=D0=B0=D0=B7=D0=BC=D0=B5=D1=82=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B1=D0=BE=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/handlers/commands/users/emoji.py | 227 +++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 bot/handlers/commands/users/emoji.py diff --git a/bot/handlers/commands/users/emoji.py b/bot/handlers/commands/users/emoji.py new file mode 100644 index 0000000..4744966 --- /dev/null +++ b/bot/handlers/commands/users/emoji.py @@ -0,0 +1,227 @@ +""" +Обработчик команды /emoji для извлечения ID премиум эмодзи +""" +from aiogram import Router +from aiogram.filters import Command +from aiogram.types import Message +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from bot.filters.admin import IsAdmin +from configs import settings, COMMANDS +from middleware.loggers import logger + +__all__ = ("router",) + +router: Router = Router(name="emoji_extractor_router") + +MAX_MSG_LEN = 3800 # Безопасный лимит (4096 - запас) +SEPARATOR = "\n" + "─" * 30 + "\n\n" + + +# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ================= + +def _utf16_slice(text: str, offset: int, length: int) -> str: + """ + Корректно извлекает подстроку с учётом UTF-16 смещений Telegram API. + Telegram передаёт offset/length в UTF-16 code units, а не в Unicode codepoints. + """ + encoded = text.encode("utf-16-le") + return encoded[offset * 2 : (offset + length) * 2].decode("utf-16-le") + + +def extract_custom_emojis(message: Message) -> list[dict]: + """ + Извлекает все кастомные эмодзи из сообщения (текст + подпись). + + Returns: + Список словарей: {"char": str, "id": str, "offset": int} + """ + text = message.text or message.caption + entities = message.entities or message.caption_entities + + if not text or not entities: + return [] + + custom_emojis = [] + for entity in entities: + if entity.type == "custom_emoji": + emoji_char = _utf16_slice(text, entity.offset, entity.length) + custom_emojis.append({ + "char": emoji_char, + "id": entity.custom_emoji_id, + "offset": entity.offset, + }) + + return custom_emojis + + +def format_emoji_html(emoji_char: str, emoji_id: str) -> str: + return f'{emoji_char}' + + +def escape_html(text: str) -> str: + return ( + text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + ) + + +def _build_emoji_block(idx: int, emoji_data: dict, is_last: bool) -> str: + """Формирует текстовый блок для одного эмодзи.""" + emoji_char = emoji_data["char"] + emoji_id = emoji_data["id"] + html_code = format_emoji_html(emoji_char, emoji_id) + html_escaped = escape_html(html_code) + + block = ( + f"{idx}. Эмодзи: {emoji_char}\n" + f"📋 ID: {emoji_id}\n\n" + f"📝 HTML-код:\n" + f"{html_escaped}\n\n" + f"🎨 Превью: {html_code}\n" + ) + + if not is_last: + block += SEPARATOR + + return block + + +def build_pages(custom_emojis: list[dict]) -> list[str]: + """ + Разбивает список эмодзи на страницы, каждая не длиннее MAX_MSG_LEN. + Возвращает список готовых HTML-строк для отправки. + """ + total = len(custom_emojis) + pages: list[str] = [] + current_page = "" + + for idx, emoji_data in enumerate(custom_emojis, 1): + is_last = (idx == total) + block = _build_emoji_block(idx, emoji_data, is_last) + + if current_page and len(current_page) + len(block) > MAX_MSG_LEN: + pages.append(current_page) + current_page = block + else: + current_page += block + + if current_page: + pages.append(current_page) + + return pages + + +# ================= КОМАНДА /EMOJI ================= + +@router.message( + Command(*COMMANDS.get("emoji", ["emoji"]), prefix=settings.PREFIX, ignore_case=True), + IsAdmin() +) +async def emoji_extractor_cmd(message: Message) -> None: + if not message.reply_to_message: + await message.answer( + "❌ Используйте команду в ответ на сообщение\n\n" + "📝 Как использовать:\n" + "1. Ответьте на сообщение с премиум эмодзи\n" + "2. Напишите /emoji\n\n" + "💡 Бот извлечёт все кастомные эмодзи и покажет HTML-код", + parse_mode="HTML", + ) + return + + replied_message = message.reply_to_message + custom_emojis = extract_custom_emojis(replied_message) + + if not custom_emojis: + await message.answer( + "⚠️ Кастомные эмодзи не найдены\n\n" + "В этом сообщении нет премиум эмодзи.\n\n" + "💡 Попробуйте ответить на сообщение с анимированными эмодзи", + parse_mode="HTML", + ) + return + + total = len(custom_emojis) + pages = build_pages(custom_emojis) + total_pages = len(pages) + + ikb = InlineKeyboardBuilder() + ikb.button(text="✖️ Закрыть", callback_data="emoji_close") + + try: + for page_num, page_content in enumerate(pages, 1): + # Заголовок только на первой странице + if page_num == 1: + header = f"✨ НАЙДЕНО ЭМОДЗИ: {total}\n\n" + else: + header = f"✨ ПРОДОЛЖЕНИЕ ({page_num}/{total_pages})\n\n" + + # Подвал только на последней странице + footer = ( + "\n\n💡 Скопируйте HTML-код и используйте в своих сообщениях" + if page_num == total_pages + else "" + ) + + # Кнопка закрытия только на последней странице + markup = ikb.as_markup() if page_num == total_pages else None + + await message.answer( + text=header + page_content + footer, + parse_mode="HTML", + reply_markup=markup, + ) + + logger.info( + f"Извлечено {total} кастомных эмодзи ({total_pages} стр.) " + f"админом {message.from_user.id}", + log_type="EMOJI_EXTRACT", + ) + + except Exception as e: + logger.error(f"Ошибка отправки эмодзи: {e}", log_type="ERROR") + await message.answer( + "❌ Ошибка извлечения эмодзи\n\n" + "Попробуйте позже или обратитесь к разработчику.", + parse_mode="HTML", + ) + + +# ================= ОБРАБОТЧИК КНОПКИ ЗАКРЫТИЯ ================= + +@router.callback_query(lambda c: c.data == "emoji_close", IsAdmin()) +async def emoji_close_callback(callback) -> None: + try: + await callback.message.delete() + await callback.answer("✅ Закрыто") + except Exception as e: + logger.error(f"Ошибка удаления сообщения с эмодзи: {e}", log_type="ERROR") + await callback.answer("❌ Не удалось удалить", show_alert=True) + + +# ================= ДОПОЛНИТЕЛЬНАЯ КОМАНДА /EMOJIHELP ================= + +@router.message( + Command(*COMMANDS.get("emojihelp", ["emojihelp"]), prefix=settings.PREFIX, ignore_case=True), + IsAdmin() +) +async def emoji_help_cmd(message: Message) -> None: + text = ( + "🎨 РАБОТА С КАСТОМНЫМИ ЭМОДЗИ\n\n" + "📝 Команда /emoji\n" + "Извлекает ID премиум эмодзи из сообщения\n\n" + "🔧 Как использовать:\n" + "1️⃣ Ответьте на сообщение с эмодзи\n" + "2️⃣ Напишите /emoji\n" + "3️⃣ Скопируйте HTML-код\n\n" + "💻 Формат HTML-кода:\n" + '<tg-emoji emoji-id="ID">fallback</tg-emoji>\n\n' + "⚠️ Важно:\n" + '├─ Используйте parse_mode="HTML"\n' + "├─ Пользователи без Premium видят fallback\n" + "└─ Работает только с кастомными эмодзи\n\n" + "💡 Попробуйте отправить эмодзи и ответить командой /emoji" + ) + await message.answer(text, parse_mode="HTML")