Files
Solo_bot/utils/custom_emojis.py
T
2026-04-14 07:19:14 +00:00

407 lines
14 KiB
Python

from __future__ import annotations
import re
from typing import Any
from aiogram import Bot
from aiogram.enums import MessageEntityType
from aiogram.types import MessageEntity
from logger import logger
_PLACEHOLDER_CACHE: dict[str, str] = {}
_BOT: Bot | None = None
_MARKER_RE = re.compile(r"\{emoji:(\d+)\}|\[emoji:(\d+)\]")
_CODE_BLOCK_RE = re.compile(r"<code[^>]*>(.*?)</code>", re.IGNORECASE | re.DOTALL)
_PRE_BLOCK_RE = re.compile(r"<pre[^>]*>(.*?)</pre>", 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 <code> and <pre> tags to skip replacements."""
ranges: list[tuple[int, int]] = []
for match in _CODE_BLOCK_RE.finditer(text):
ranges.append((match.start(1), match.end(1)))
for match in _PRE_BLOCK_RE.finditer(text):
ranges.append((match.start(1), match.end(1)))
return ranges
def _is_in_ranges(pos: int, ranges: list[tuple[int, int]]) -> bool:
for start, end in ranges:
if start <= pos < end:
return True
return False
def _utf16_len(text: str) -> int:
"""Length of string in UTF-16 code units."""
return len(text.encode("utf-16-le")) // 2
async def _fetch_placeholder(emoji_id: str) -> str:
"""Resolve a custom emoji id to a visible placeholder emoji."""
if emoji_id in _PLACEHOLDER_CACHE:
return _PLACEHOLDER_CACHE[emoji_id]
if _BOT is None:
return "😀"
try:
stickers = await _BOT.get_custom_emoji_stickers(custom_emoji_ids=[emoji_id])
if stickers:
sticker = stickers[0]
placeholder = None
if getattr(sticker, "emoji", None):
placeholder = sticker.emoji
elif getattr(sticker, "alt", None):
placeholder = sticker.alt
if placeholder:
_PLACEHOLDER_CACHE[emoji_id] = placeholder
return placeholder
except Exception:
pass
return "😀"
async def _replace_markers(text: str) -> tuple[str, list[MessageEntity]]:
"""Replace markers with placeholders and build custom emoji entities."""
if not text:
return text, []
entities: list[MessageEntity] = []
protected_ranges = _get_protected_ranges(text)
matches = [m for m in _MARKER_RE.finditer(text) if not _is_in_ranges(m.start(), protected_ranges)]
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)
start, end = match.start(), match.end()
placeholder = await _fetch_placeholder(emoji_id)
replacements.append((start, end, str(emoji_id), 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)
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_utf16,
length=length_utf16,
custom_emoji_id=emoji_id,
),
)
offset_utf16 += length_utf16
pos = end
return result, entities
def _parse_html_entities(text: str) -> list[MessageEntity]:
"""Parse simple HTML tags to entities (bold/italic/etc, links)."""
entities: list[MessageEntity] = []
link_open = re.compile(r'<a href="([^"]+)"\s*>|<a href=\'([^\']+)\'\s*>')
link_close = re.compile(r"</a>")
link_stack: list[dict[str, Any]] = []
for match in link_open.finditer(text):
url = match.group(1) or match.group(2)
link_stack.append({"open_end": match.end(), "url": url})
for close in link_close.finditer(text):
if not link_stack:
continue
open_tag = link_stack.pop()
open_pos = open_tag["open_end"]
close_pos = close.start()
content = text[open_pos:close_pos]
entities.append(
MessageEntity(
type=MessageEntityType.TEXT_LINK,
offset=_utf16_len(text[:open_pos]),
length=_utf16_len(content),
url=open_tag["url"],
)
)
tag_map: list[tuple[str, str, MessageEntityType]] = [
(r"<b\s*>", r"</b>", MessageEntityType.BOLD),
(r"<strong\s*>", r"</strong>", MessageEntityType.BOLD),
(r"<i\s*>", r"</i>", MessageEntityType.ITALIC),
(r"<em\s*>", r"</em>", MessageEntityType.ITALIC),
(r"<u\s*>", r"</u>", MessageEntityType.UNDERLINE),
(r"<ins\s*>", r"</ins>", MessageEntityType.UNDERLINE),
(r"<s\s*>", r"</s>", MessageEntityType.STRIKETHROUGH),
(r"<strike\s*>", r"</strike>", MessageEntityType.STRIKETHROUGH),
(r"<del\s*>", r"</del>", MessageEntityType.STRIKETHROUGH),
(r"<code\s*>", r"</code>", MessageEntityType.CODE),
(r"<pre\s*>", r"</pre>", MessageEntityType.PRE),
(r"<blockquote\s*>", r"</blockquote>", MessageEntityType.BLOCKQUOTE),
]
for open_pat, close_pat, entity_type in tag_map:
for open in re.finditer(open_pat, text):
close = re.search(close_pat, text[open.end() :])
if not close:
continue
close_pos = open.end() + close.start()
content = text[open.end() : close_pos]
entities.append(
MessageEntity(
type=entity_type,
offset=_utf16_len(text[: open.end()]),
length=_utf16_len(content),
)
)
return entities
async def _process_text(
text: str, entities: list[MessageEntity] | None = None
) -> tuple[str, list[MessageEntity] | None]:
"""Apply custom emoji markers and merge entities."""
processed, custom_entities = await _replace_markers(text)
if not custom_entities:
return text, entities
html_entities: list[MessageEntity] = []
if "<" in processed and ">" in processed:
html_entities = _parse_html_entities(processed)
if not html_entities and not custom_entities and not entities:
return text, None
if html_entities:
plain = []
html_pos = 0
utf16_map: dict[int, int] = {}
plain_utf16 = 0
html_utf16 = 0
while html_pos < len(processed):
ch = processed[html_pos]
if ch == "<":
while html_pos < len(processed) and processed[html_pos] != ">":
ch = processed[html_pos]
ch_len = _utf16_len(ch)
for i in range(ch_len):
utf16_map[html_utf16 + i] = plain_utf16
html_utf16 += ch_len
html_pos += 1
if html_pos < len(processed):
ch = processed[html_pos]
ch_len = _utf16_len(ch)
for i in range(ch_len):
utf16_map[html_utf16 + i] = plain_utf16
html_utf16 += ch_len
html_pos += 1
continue
plain.append(ch)
ch_len = _utf16_len(ch)
for i in range(ch_len):
utf16_map[html_utf16 + i] = plain_utf16 + i
plain_utf16 += ch_len
html_utf16 += ch_len
html_pos += 1
def remap(offset: int) -> int:
if offset in utf16_map:
return utf16_map[offset]
keys = sorted(utf16_map.keys())
best = None
for key in keys:
if key <= offset:
best = key
else:
break
return utf16_map[best] if best is not None else offset
remapped_html: list[MessageEntity] = []
for ent in html_entities:
new_offset = remap(ent.offset)
end_offset = remap(ent.offset + ent.length)
data = ent.model_dump()
data["offset"] = new_offset
data["length"] = end_offset - new_offset
remapped_html.append(MessageEntity(**data))
remapped_custom: list[MessageEntity] = []
for ent in custom_entities:
data = ent.model_dump()
data["offset"] = remap(ent.offset)
remapped_custom.append(MessageEntity(**data))
merged: list[MessageEntity] = []
merged.extend(remapped_html)
merged.extend(remapped_custom)
if entities:
merged.extend(entities)
merged.sort(key=lambda e: e.offset)
return "".join(plain), merged
merged: list[MessageEntity] = []
merged.extend(custom_entities)
if entities:
merged.extend(entities)
merged.sort(key=lambda e: e.offset)
return processed, merged
def patch_bot_methods() -> bool:
"""Patch Message methods to auto-handle custom emojis."""
global _BOT
try:
from aiogram.types import Message
from bot import bot
_BOT = bot
if not hasattr(Message, "_custom_emojis_patched"):
Message._custom_emojis_patched = True
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 patched_answer(self, text: str, entities: list[MessageEntity] | None = None, **kwargs):
processed, merged = await _process_text(text, entities)
if merged:
_set_parse_mode_none(kwargs)
return await self._original_answer(text=processed, entities=merged, **kwargs)
async def patched_edit_text(self, text: str, entities: list[MessageEntity] | None = None, **kwargs):
processed, merged = await _process_text(text, entities)
if merged:
_set_parse_mode_none(kwargs)
return await self._original_edit_text(text=processed, entities=merged, **kwargs)
async def patched_edit_caption(
self,
caption: str | None = None,
caption_entities: list[MessageEntity] | None = None,
**kwargs,
):
if not caption:
return await self._original_edit_caption(caption=caption, caption_entities=caption_entities, **kwargs)
processed, merged = await _process_text(caption, caption_entities)
if merged:
_set_parse_mode_none(kwargs)
return await self._original_edit_caption(caption=processed, caption_entities=merged, **kwargs)
async def patched_answer_photo(
self,
photo: Any,
caption: str | None = None,
caption_entities: list[MessageEntity] | None = None,
**kwargs,
):
if not caption:
return await self._original_answer_photo(
photo=photo, caption=caption, caption_entities=caption_entities, **kwargs
)
processed, merged = await _process_text(caption, caption_entities)
if merged:
_set_parse_mode_none(kwargs)
return await self._original_answer_photo(photo=photo, caption=processed, caption_entities=merged, **kwargs)
async def patched_answer_video(
self,
video: Any,
caption: str | None = None,
caption_entities: list[MessageEntity] | None = None,
**kwargs,
):
if not caption:
return await self._original_answer_video(
video=video, caption=caption, caption_entities=caption_entities, **kwargs
)
processed, merged = await _process_text(caption, caption_entities)
if merged:
_set_parse_mode_none(kwargs)
return await self._original_answer_video(video=video, caption=processed, caption_entities=merged, **kwargs)
async def patched_answer_animation(
self,
animation: Any,
caption: str | None = None,
caption_entities: list[MessageEntity] | None = None,
**kwargs,
):
if not caption:
return await self._original_answer_animation(
animation=animation,
caption=caption,
caption_entities=caption_entities,
**kwargs,
)
processed, merged = await _process_text(caption, caption_entities)
if merged:
_set_parse_mode_none(kwargs)
return await self._original_answer_animation(
animation=animation,
caption=processed,
caption_entities=merged,
**kwargs,
)
async def patched_edit_media(self, media: Any, **kwargs):
if hasattr(media, "caption") and media.caption:
processed, merged = await _process_text(media.caption, getattr(media, "caption_entities", None))
media.caption = processed
if merged:
if hasattr(media, "parse_mode"):
media.parse_mode = None
kwargs["parse_mode"] = None
media.caption_entities = merged
return await self._original_edit_media(media=media, **kwargs)
Message.answer = patched_answer
Message.edit_text = patched_edit_text
Message.edit_caption = patched_edit_caption
Message.answer_photo = patched_answer_photo
Message.answer_video = patched_answer_video
Message.answer_animation = patched_answer_animation
Message.edit_media = patched_edit_media
return True
except Exception as e:
logger.error(f"[CustomEmojis] Patch failed: {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] Init failed: {e}", exc_info=True)
return False