From b62be683e179c0cb5805949af47ebb980e880a04 Mon Sep 17 00:00:00 2001 From: Vladless Date: Wed, 11 Feb 2026 19:54:17 +0300 Subject: [PATCH] fixing a lot of custom emojis --- handlers/admin/module/module_handler.py | 9 +- requirements.txt | 2 +- utils/custom_emojis.py | 533 ++---------------------- utils/modules_loader.py | 3 + utils/modules_manager.py | 16 +- 5 files changed, 56 insertions(+), 507 deletions(-) diff --git a/handlers/admin/module/module_handler.py b/handlers/admin/module/module_handler.py index a50e21e4..7abb009a 100644 --- a/handlers/admin/module/module_handler.py +++ b/handlers/admin/module/module_handler.py @@ -21,9 +21,12 @@ def list_installed_modules() -> list[tuple[str, str | None]]: if not os.path.isdir(base): return [] items: list[tuple[str, str | None]] = [] - for name in sorted(os.listdir(base)): - path = os.path.join(base, name) - if os.path.isdir(path) and not name.startswith("."): + for raw_name in sorted(os.listdir(base)): + path = os.path.join(base, raw_name) + if os.path.isdir(path) and not raw_name.startswith("."): + name = (raw_name or "").strip() + if not name: + continue ver = None vp = os.path.join(path, "VERSION") if os.path.isfile(vp): diff --git a/requirements.txt b/requirements.txt index 84536f07..2395373d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ asyncpg==0.30.0 attrs==24.2.0 babel==2.17.0 cachetools==5.5.1 -certifi==2023.11.17 # aiocryptopay требует certifi<2024; обновить после выхода совместимой версии aiocryptopay +certifi==2023.11.17 cffi==1.17.1 charset-normalizer==3.4.0 click==8.2.2 diff --git a/utils/custom_emojis.py b/utils/custom_emojis.py index 30f34534..108d9bb0 100644 --- a/utils/custom_emojis.py +++ b/utils/custom_emojis.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from typing import Any, Iterable +from typing import Any from aiogram import Bot from aiogram.enums import MessageEntityType @@ -17,6 +17,10 @@ _CODE_BLOCK_RE = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTAL _PRE_BLOCK_RE = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTALL) +def _set_parse_mode_none(kwargs: dict[str, Any]) -> None: + kwargs["parse_mode"] = None + + def _get_protected_ranges(text: str) -> list[tuple[int, int]]: """Return ranges inside and
 tags to skip replacements."""
     ranges: list[tuple[int, int]] = []
@@ -67,35 +71,48 @@ async def _fetch_placeholder(emoji_id: str) -> str:
 
 
 async def _replace_markers(text: str) -> tuple[str, list[MessageEntity]]:
-    """Replace markers with placeholders and build custom emoji entities."""
+    """Replace markers with placeholders and build custom emoji entities.
+    """
     if not text:
         return text, []
 
     entities: list[MessageEntity] = []
-    result = text
-
     protected_ranges = _get_protected_ranges(text)
     matches = [m for m in _MARKER_RE.finditer(text) if not _is_in_ranges(m.start(), protected_ranges)]
-    for match in reversed(matches):
+    if not matches:
+        return text, []
+
+    replacements: list[tuple[int, int, str, str]] = []
+    for match in matches:
         emoji_id = match.group(1) or match.group(2)
-        marker = match.group(0)
-        marker_pos = match.start()
-
+        start, end = match.start(), match.end()
         placeholder = await _fetch_placeholder(emoji_id)
-        result = result[:marker_pos] + placeholder + result[marker_pos + len(marker) :]
+        replacements.append((start, end, str(emoji_id), placeholder))
 
-        offset = _utf16_len(result[:marker_pos])
-        length = _utf16_len(placeholder)
+    parts: list[str] = []
+    pos = 0
+    for start, end, _emoji_id, placeholder in replacements:
+        parts.append(text[pos:start])
+        parts.append(placeholder)
+        pos = end
+    parts.append(text[pos:])
+    result = "".join(parts)
 
-        entities.insert(
-            0,
+    offset_utf16 = 0
+    pos = 0
+    for start, end, emoji_id, placeholder in replacements:
+        offset_utf16 += _utf16_len(text[pos:start])
+        length_utf16 = _utf16_len(placeholder)
+        entities.append(
             MessageEntity(
                 type=MessageEntityType.CUSTOM_EMOJI,
-                offset=offset,
-                length=length,
-                custom_emoji_id=str(emoji_id),
+                offset=offset_utf16,
+                length=length_utf16,
+                custom_emoji_id=emoji_id,
             ),
         )
+        offset_utf16 += length_utf16
+        pos = end
 
     return result, entities
 
@@ -253,11 +270,6 @@ async def _process_text(
     return processed, merged
 
 
-def _set_parse_mode_none(kwargs: dict[str, Any]) -> None:
-    if kwargs.get("parse_mode") is not None:
-        kwargs["parse_mode"] = None
-
-
 def patch_bot_methods() -> bool:
     """Patch Message methods to auto-handle custom emojis."""
     global _BOT
@@ -390,482 +402,3 @@ def initialize_custom_emojis() -> bool:
     except Exception as e:
         logger.error(f"[CustomEmojis] Init failed: {e}", exc_info=True)
         return False
-
-
-"""
-Support custom Telegram emojis in bot texts.
-Allows writing custom emoji IDs in texts via a special syntax.
-"""
-
-import re
-from typing import Any
-
-from aiogram import Bot
-from aiogram.enums import MessageEntityType
-from aiogram.types import MessageEntity
-
-from logger import logger
-
-_emoji_placeholder_cache: dict[str, str] = {}
-_bot_instance: Bot | None = None
-
-
-async def _get_emoji_placeholder(emoji_id: str) -> str:
-    """Fetch placeholder emoji for custom_emoji_id via API, with cache."""
-    if emoji_id in _emoji_placeholder_cache:
-        return _emoji_placeholder_cache[emoji_id]
-
-    if _bot_instance is None:
-        return "😀"
-
-    try:
-        stickers = await _bot_instance.get_custom_emoji_stickers(custom_emoji_ids=[emoji_id])
-        if stickers:
-            sticker = stickers[0]
-            placeholder = None
-            if hasattr(sticker, "emoji") and sticker.emoji:
-                placeholder = sticker.emoji
-            elif hasattr(sticker, "alt") and sticker.alt:
-                placeholder = sticker.alt
-
-            if placeholder:
-                _emoji_placeholder_cache[emoji_id] = placeholder
-                return placeholder
-    except Exception:
-        pass
-
-    return "😀"
-
-
-def _get_utf16_length(text: str) -> int:
-    """Calculate length in UTF-16 code units."""
-    return len(text.encode("utf-16-le")) // 2
-
-
-async def parse_custom_emoji_markers(text: str) -> tuple[str, list[MessageEntity]]:
-    """Parse text with custom emoji markers to placeholders and entities."""
-    if not text:
-        return text, []
-
-    entities: list[MessageEntity] = []
-    result_text = text
-    pattern = r"\{emoji:(\d+)\}|\[emoji:(\d+)\]"
-    matches = list(re.finditer(pattern, text))
-
-    for match in reversed(matches):
-        emoji_id = match.group(1) or match.group(2)
-        marker_text = match.group(0)
-        marker_pos_in_result = match.start()
-
-        emoji_placeholder = await _get_emoji_placeholder(emoji_id)
-        result_text = (
-            result_text[:marker_pos_in_result]
-            + emoji_placeholder
-            + result_text[marker_pos_in_result + len(marker_text) :]
-        )
-
-        text_before = result_text[:marker_pos_in_result]
-        offset_utf16 = _get_utf16_length(text_before)
-        placeholder_utf16_length = _get_utf16_length(emoji_placeholder)
-
-        entity = MessageEntity(
-            type=MessageEntityType.CUSTOM_EMOJI,
-            offset=offset_utf16,
-            length=placeholder_utf16_length,
-            custom_emoji_id=str(emoji_id),
-        )
-        entities.insert(0, entity)
-
-    return result_text, entities
-
-
-def _parse_html_entities(text: str) -> list[MessageEntity]:
-    """Parse HTML markup and build entities manually."""
-    entities: list[MessageEntity] = []
-
-    link_patterns = [
-        (r'', r""),
-        (r"", r""),
-    ]
-
-    link_tags = []
-    for open_pattern, close_pattern in link_patterns:
-        for open_match in re.finditer(open_pattern, text):
-            url = open_match.group(1)
-            link_tags.append({
-                "type": "open",
-                "url": url,
-                "open_start": open_match.start(),
-                "open_end": open_match.end(),
-                "close_pattern": close_pattern,
-            })
-
-    for close_match in re.finditer(r"", text):
-        link_tags.append({"type": "close", "pos": close_match.start()})
-
-    link_tags.sort(key=lambda x: x.get("open_start", x.get("pos", 0)))
-
-    link_stacks = []
-    tag_positions = []
-
-    for tag in link_tags:
-        if tag["type"] == "open":
-            link_stacks.append(tag)
-        else:
-            if link_stacks:
-                matching_open = link_stacks.pop()
-                open_pos = matching_open["open_end"]
-                close_pos = tag["pos"]
-                content = text[open_pos:close_pos]
-
-                tag_positions.append({
-                    "open_pos": open_pos,
-                    "close_pos": close_pos,
-                    "content": content,
-                    "entity_type": "text_link",
-                    "url": matching_open["url"],
-                })
-
-    other_patterns = [
-        (r"", r"", "bold"),
-        (r"", r"", "bold"),
-        (r"", r"", "italic"),
-        (r"", r"", "italic"),
-        (r"", r"", "underline"),
-        (r"", r"", "underline"),
-        (r"", r"", "strikethrough"),
-        (r"", r"", "strikethrough"),
-        (r"", r"", "strikethrough"),
-        (r"", r"", "code"),
-        (r"", r"
", "pre"), - (r"", r"", "blockquote"), - ] - - other_tags = [] - for open_pattern, close_pattern, entity_type in other_patterns: - for open_match in re.finditer(open_pattern, text): - other_tags.append({ - "type": "open", - "entity_type": entity_type, - "open_end": open_match.end(), - "close_pattern": close_pattern, - }) - - for close_match in re.finditer(close_pattern, text): - other_tags.append({"type": "close", "entity_type": entity_type, "pos": close_match.start()}) - - other_tags.sort(key=lambda x: x.get("open_end", x.get("pos", 0))) - - other_stacks: dict[str, list[dict[str, Any]]] = {} - - for tag in other_tags: - entity_type = tag["entity_type"] - - if tag["type"] == "open": - if entity_type not in other_stacks: - other_stacks[entity_type] = [] - other_stacks[entity_type].append(tag) - else: - if entity_type in other_stacks and other_stacks[entity_type]: - matching_open = other_stacks[entity_type].pop() - open_pos = matching_open["open_end"] - close_pos = tag["pos"] - content = text[open_pos:close_pos] - - tag_positions.append({ - "open_pos": open_pos, - "close_pos": close_pos, - "content": content, - "entity_type": entity_type, - "url": None, - }) - - tag_positions.sort(key=lambda x: x["open_pos"]) - - for tag_info in tag_positions: - content = tag_info["content"] - open_pos = tag_info["open_pos"] - entity_type = tag_info["entity_type"] - url = tag_info["url"] - - text_before_content = text[:open_pos] - offset_utf16 = _get_utf16_length(text_before_content) - - length_utf16 = _get_utf16_length(content) - - entity_dict = { - "type": MessageEntityType(entity_type), - "offset": offset_utf16, - "length": length_utf16, - } - if url: - entity_dict["url"] = url - - entity = MessageEntity(**entity_dict) - entities.append(entity) - - return entities - - -async def process_text_with_custom_emojis(text: str) -> tuple[str, list[MessageEntity] | None]: - """Process text with custom emoji markers.""" - if not text or not isinstance(text, str): - return text, None - - processed_text, entities = await parse_custom_emoji_markers(text) - return (processed_text, entities) if entities else (text, None) - - -def patch_bot_methods() -> bool: - """Patch Message methods to handle custom emojis.""" - global _bot_instance - try: - from aiogram.types import Message - from bot import bot - - _bot_instance = bot - - if not hasattr(Message, "_original_answer"): - Message._original_answer = Message.answer - Message._original_edit_text = Message.edit_text - Message._original_edit_caption = Message.edit_caption - Message._original_answer_photo = Message.answer_photo - Message._original_answer_video = Message.answer_video - Message._original_answer_animation = Message.answer_animation - Message._original_edit_media = Message.edit_media - - async def process_text_and_entities( - text: str, - entities: list[MessageEntity] | None = None, - parse_mode: str | None = None, - ): - """Process text and entities for custom emojis.""" - if not text: - return text, entities - - processed_text_with_html, custom_entities = await process_text_with_custom_emojis(text) - - html_entities_with_html = [] - if custom_entities and "<" in processed_text_with_html and ">" in processed_text_with_html: - html_entities_with_html = _parse_html_entities(processed_text_with_html) - - if html_entities_with_html or custom_entities: - text_without_html = "" - utf16_offset_map: dict[int, int] = {} - html_pos = 0 - plain_utf16 = 0 - html_utf16 = 0 - - while html_pos < len(processed_text_with_html): - if processed_text_with_html[html_pos] == "<": - while html_pos < len(processed_text_with_html) and processed_text_with_html[html_pos] != ">": - char = processed_text_with_html[html_pos] - char_utf16_len = _get_utf16_length(char) - for i in range(char_utf16_len): - utf16_offset_map[html_utf16 + i] = plain_utf16 - html_utf16 += char_utf16_len - html_pos += 1 - if html_pos < len(processed_text_with_html): - char = processed_text_with_html[html_pos] - char_utf16_len = _get_utf16_length(char) - for i in range(char_utf16_len): - utf16_offset_map[html_utf16 + i] = plain_utf16 - html_utf16 += char_utf16_len - html_pos += 1 - else: - char = processed_text_with_html[html_pos] - text_without_html += char - char_utf16_len = _get_utf16_length(char) - for i in range(char_utf16_len): - utf16_offset_map[html_utf16 + i] = plain_utf16 + i - plain_utf16 += char_utf16_len - html_utf16 += char_utf16_len - html_pos += 1 - - def recalculate_offset(entity_offset_utf16: int) -> int: - """Recalculate UTF-16 offset from HTML to plain text.""" - if entity_offset_utf16 in utf16_offset_map: - return utf16_offset_map[entity_offset_utf16] - - sorted_keys = sorted(utf16_offset_map.keys()) - best_match = None - for key in sorted_keys: - if key <= entity_offset_utf16: - best_match = key - else: - break - - return utf16_offset_map[best_match] if best_match is not None else entity_offset_utf16 - - html_entities = [] - for entity in html_entities_with_html: - new_offset = recalculate_offset(entity.offset) - entity_end_offset = entity.offset + entity.length - new_end_offset = recalculate_offset(entity_end_offset) - new_length = new_end_offset - new_offset - entity_dict = entity.model_dump() - entity_dict["offset"] = new_offset - entity_dict["length"] = new_length - html_entities.append(MessageEntity(**entity_dict)) - - corrected_custom_entities = [] - for entity in custom_entities: - new_offset = recalculate_offset(entity.offset) - entity_dict = entity.model_dump() - entity_dict["offset"] = new_offset - corrected_custom_entities.append(MessageEntity(**entity_dict)) - - final_entities: list[MessageEntity] = [] - if html_entities: - final_entities.extend(html_entities) - if corrected_custom_entities: - final_entities.extend(corrected_custom_entities) - if entities: - final_entities.extend(entities) - - if final_entities: - final_entities = sorted(final_entities, key=lambda e: e.offset) - - return text_without_html, final_entities if final_entities else None - - return text, entities - - async def patched_message_answer(self, text: str, entities: list[MessageEntity] | None = None, **kwargs): - """Patched Message.answer.""" - processed_text, final_entities = await process_text_and_entities(text, entities, kwargs.get("parse_mode")) - if final_entities: - kwargs["parse_mode"] = None - return await self._original_answer( - text=processed_text, entities=final_entities if final_entities else None, **kwargs - ) - - async def patched_message_edit_text(self, text: str, entities: list[MessageEntity] | None = None, **kwargs): - """Patched Message.edit_text.""" - processed_text, final_entities = await process_text_and_entities(text, entities, kwargs.get("parse_mode")) - if final_entities: - kwargs["parse_mode"] = None - return await self._original_edit_text(text=processed_text, entities=final_entities, **kwargs) - - async def patched_message_edit_caption( - self, - caption: str | None = None, - caption_entities: list[MessageEntity] | None = None, - **kwargs, - ): - """Patched Message.edit_caption.""" - if not caption: - return await self._original_edit_caption(caption=caption, caption_entities=caption_entities, **kwargs) - processed_caption, final_entities = await process_text_and_entities( - caption, caption_entities, kwargs.get("parse_mode") - ) - if final_entities: - kwargs["parse_mode"] = None - return await self._original_edit_caption( - caption=processed_caption, caption_entities=final_entities, **kwargs - ) - - async def patched_message_answer_photo( - self, - photo: Any, - caption: str | None = None, - caption_entities: list[MessageEntity] | None = None, - **kwargs, - ): - """Patched Message.answer_photo.""" - if not caption: - return await self._original_answer_photo( - photo=photo, caption=caption, caption_entities=caption_entities, **kwargs - ) - processed_caption, final_entities = await process_text_and_entities( - caption, caption_entities, kwargs.get("parse_mode") - ) - if final_entities: - kwargs["parse_mode"] = None - return await self._original_answer_photo( - photo=photo, caption=processed_caption, caption_entities=final_entities, **kwargs - ) - - async def patched_message_answer_video( - self, - video: Any, - caption: str | None = None, - caption_entities: list[MessageEntity] | None = None, - **kwargs, - ): - """Patched Message.answer_video.""" - if not caption: - return await self._original_answer_video( - video=video, caption=caption, caption_entities=caption_entities, **kwargs - ) - processed_caption, final_entities = await process_text_and_entities( - caption, caption_entities, kwargs.get("parse_mode") - ) - if final_entities: - kwargs["parse_mode"] = None - return await self._original_answer_video( - video=video, caption=processed_caption, caption_entities=final_entities, **kwargs - ) - - async def patched_message_answer_animation( - self, - animation: Any, - caption: str | None = None, - caption_entities: list[MessageEntity] | None = None, - **kwargs, - ): - """Patched Message.answer_animation.""" - if not caption: - return await self._original_answer_animation( - animation=animation, - caption=caption, - caption_entities=caption_entities, - **kwargs, - ) - processed_caption, final_entities = await process_text_and_entities( - caption, caption_entities, kwargs.get("parse_mode") - ) - if final_entities: - kwargs["parse_mode"] = None - return await self._original_answer_animation( - animation=animation, - caption=processed_caption, - caption_entities=final_entities, - **kwargs, - ) - - async def patched_message_edit_media(self, media: Any, **kwargs): - """Patched Message.edit_media.""" - if hasattr(media, "caption") and media.caption: - processed_caption, final_entities = await process_text_and_entities( - media.caption, getattr(media, "caption_entities", None), kwargs.get("parse_mode") - ) - media.caption = processed_caption - if final_entities: - if hasattr(media, "parse_mode"): - media.parse_mode = None - kwargs["parse_mode"] = None - media.caption_entities = final_entities - return await self._original_edit_media(media=media, **kwargs) - - Message.answer = patched_message_answer - Message.edit_text = patched_message_edit_text - Message.edit_caption = patched_message_edit_caption - Message.answer_photo = patched_message_answer_photo - Message.answer_video = patched_message_answer_video - Message.answer_animation = patched_message_answer_animation - Message.edit_media = patched_message_edit_media - - return True - - except Exception as e: - logger.error(f"[CustomEmojis] Error while patching bot methods: {e}", exc_info=True) - return False - - -def initialize_custom_emojis() -> bool: - """Initialize custom emoji support.""" - try: - return patch_bot_methods() - except Exception as e: - logger.error(f"[CustomEmojis] Error during initialization: {e}", exc_info=True) - return False diff --git a/utils/modules_loader.py b/utils/modules_loader.py index 91adb56e..482d6355 100644 --- a/utils/modules_loader.py +++ b/utils/modules_loader.py @@ -22,6 +22,7 @@ def load_modules_from_folder(folder: str = "modules") -> list[Router]: base_path = Path(folder) for _finder, name, _ispkg in pkgutil.iter_modules([str(base_path)]): + name = (name or "").strip() if not _is_safe_module_name(name): logger.warning(f"[Modules] Пропуск недопустимого имени модуля: {name!r}") continue @@ -50,6 +51,7 @@ def load_module_webhooks(folder: str = "modules") -> list[dict]: base_path = Path(folder) for _finder, name, _ispkg in pkgutil.iter_modules([str(base_path)]): + name = (name or "").strip() if not _is_safe_module_name(name): continue if not manager.should_autostart(name): @@ -74,6 +76,7 @@ def load_module_fast_flow_handlers(folder: str = "modules") -> dict: base_path = Path(folder) for _finder, name, _ispkg in pkgutil.iter_modules([str(base_path)]): + name = (name or "").strip() if not _is_safe_module_name(name): continue if not manager.should_autostart(name): diff --git a/utils/modules_manager.py b/utils/modules_manager.py index 7a2e8a84..8e7b8053 100644 --- a/utils/modules_manager.py +++ b/utils/modules_manager.py @@ -13,6 +13,10 @@ IGNORE_SUBMODULES = {"models", "schemas", "db"} STATE_FILE = os.getenv("MODULES_STATE_FILE", "storage/modules_state.json") +def _normalize_module_name(name: str | None) -> str: + return (name or "").strip() + + class ModuleRecord: def __init__(self, name: str, pkg: str) -> None: self.name = name @@ -36,7 +40,8 @@ class ModulesManager: if os.path.isfile(STATE_FILE): with open(STATE_FILE, encoding="utf-8") as f: data = json.load(f) - self.disabled = set(data.get("disabled", [])) + raw = data.get("disabled", []) + self.disabled = {_normalize_module_name(n) for n in raw if _normalize_module_name(n)} else: os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True) self._save_state() @@ -52,6 +57,7 @@ class ModulesManager: logger.warning(f"[Modules] Не удалось сохранить состояние: {e}") def adopt(self, name: str, router: Router): + name = _normalize_module_name(name) rec = self.registry.get(name) or ModuleRecord(name, self.pkg(name)) rec.router = router rec.enabled = True @@ -61,6 +67,7 @@ class ModulesManager: return bool(name and name.isidentifier() and "." not in name and "/" not in name and "\\" not in name) async def start(self, name: str) -> None: + name = _normalize_module_name(name) if not self._is_safe_module_name(name): raise ValueError(f"[Modules] Недопустимое имя модуля: {name!r}") rec = self.registry.get(name) or ModuleRecord(name, self.pkg(name)) @@ -95,6 +102,7 @@ class ModulesManager: logger.info(f"[Modules] {name} запущен.") async def stop(self, name: str) -> None: + name = _normalize_module_name(name) rec = self.registry.get(name) if not rec or not rec.enabled: logger.info(f"[Modules] {name} уже остановлен или не найден.") @@ -124,6 +132,7 @@ class ModulesManager: logger.info(f"[Modules] {name} остановлен.") async def restart(self, name: str) -> None: + name = _normalize_module_name(name) logger.info(f"[Modules] Перезапуск {name}...") await self.stop(name) await self.start(name) @@ -142,6 +151,7 @@ class ModulesManager: importlib.invalidate_caches() def is_enabled(self, name: str) -> bool: + name = _normalize_module_name(name) rec = self.registry.get(name) if not rec or not rec.router: return False @@ -153,10 +163,10 @@ class ModulesManager: return bool(sub and rec.router in sub) def is_disabled(self, name: str) -> bool: - return name in self.disabled + return _normalize_module_name(name) in self.disabled def should_autostart(self, name: str) -> bool: - return name not in self.disabled + return _normalize_module_name(name) not in self.disabled manager = ModulesManager()