from __future__ import annotations import re from typing import Any ZALO_TEXT_LIMIT = 2000 _FORMAT_TAG_PATTERN = re.compile(r"\{(\w+)\}(.*?)\{/(\w+)\}", re.DOTALL) def parse_zalouser_text_styles(markdown: str) -> list[dict[str, Any]]: styles: list[dict[str, Any]] = [] code_block_pattern = re.compile(r"```[\s\S]*?```") code_blocks = code_block_pattern.findall(markdown) placeholder = "\u0000" text = markdown for cb in code_blocks: text = text.replace(cb, placeholder, 1) def _capture_style(start: int, end: int, style_type: str) -> None: styles.append( { "start": start, "end": end, "type": style_type, } ) def _apply_regex(pat: re.Pattern, style_type: str, text_cursor: str) -> str: offset = 0 result = list(text_cursor) for m in pat.finditer(text_cursor): inner = m.group(2) if pat.groups >= 2 else m.group(1) raw_start = m.start() - offset raw_end = raw_start + len(inner) _capture_style(raw_start, raw_end, style_type) result[m.start() - offset : m.end() - offset] = [inner] offset += m.end() - m.start() - len(inner) return "".join(result) text = _apply_regex(re.compile(r"\*\*(.+?)\*\*"), "b", text) text = _apply_regex(re.compile(r"__(.+?)__"), "u", text) text = _apply_regex(re.compile(r"\*(.+?)\*"), "i", text) text = _apply_regex(re.compile(r"~~(.+?)~~"), "s", text) heading_pat = re.compile(r"^#{1,3}\s+(.+)$", re.MULTILINE) for m in heading_pat.finditer(text): _capture_style(m.start(1), m.end(1), "b") _capture_style(m.start(1), m.end(1), "f_18") custom_tag_pat = re.compile(r"\{(\w+)\}(.*?)\{/(\w+)\}", re.DOTALL) tag_style_map = { "red": "c_db342e", "orange": "c_f27806", "yellow": "c_f7b503", "green": "c_15a85f", "blue": "c_4391d9", "big": "f_18", } for m in custom_tag_pat.finditer(text): tag = m.group(1) if tag in tag_style_map: _capture_style(m.start(2), m.end(2), tag_style_map[tag]) pid = 0 for pid, ph in enumerate(code_blocks): text = text.replace(placeholder, ph, 1) return styles def chunk_text_for_outbound(text: str, limit: int = ZALO_TEXT_LIMIT) -> list[str]: if len(text) <= limit: return [text] chunks: list[str] = [] paragraphs = text.split("\n\n") current = "" for para in paragraphs: if len(current) + len(para) + 2 <= limit: current = f"{current}\n\n{para}" if current else para else: if current: chunks.append(current) if len(para) <= limit: current = para else: current = "" para_remain = para while len(para_remain) > limit: split_at = para_remain.rfind("\n", 0, limit) if split_at == -1: split_at = para_remain.rfind(" ", 0, limit) if split_at == -1: split_at = limit chunks.append(para_remain[:split_at].strip()) para_remain = para_remain[split_at:].strip() if para_remain: current = para_remain if current: chunks.append(current) return chunks if chunks else [text[:limit]] def resolve_reaction_icon(raw: str) -> str | None: alias = raw.strip().lower() from yuxi.channel.extensions.zalouser.types import REACTION_ALIAS_MAP if alias in REACTION_ALIAS_MAP: return REACTION_ALIAS_MAP[alias].value for icon in REACTION_ALIAS_MAP.values(): if icon.value.lower() == alias: return icon.value return None def strip_own_mentions(content: str, bot_name: str = "") -> str: if not bot_name: return content pattern = re.compile(rf"@{re.escape(bot_name)}\s*", re.IGNORECASE) return pattern.sub("", content).strip() def extract_mention_ids(content: str) -> list[str]: pattern = re.compile(r"@(\w+)") return pattern.findall(content) def format_for_zalouser_markdown(markdown: str) -> str: return markdown