from __future__ import annotations import html import re from html.parser import HTMLParser from typing import Optional from urllib.parse import urlparse try: from .config import settings except (ImportError, ValueError): from config import settings def split_message_chunks(text: str, limit: int = 4000) -> list[str]: text = str(text or "").strip() if not text: return [] chunks: list[str] = [] while len(text) > limit: split_at = text.rfind("\n", 0, limit) if split_at < limit // 2: split_at = text.rfind(" ", 0, limit) if split_at < limit // 2: split_at = limit chunks.append(text[:split_at].strip()) text = text[split_at:].strip() if text: chunks.append(text) return chunks def clean_dividers(lines: list[str]) -> list[str]: sep_pattern = re.compile(r"^\s*[━—─\-=\*\#_]{2,}\s*$") return ["" if sep_pattern.match(line) else line for line in lines] # Matches VK bracket markup: [club123|Name], [id123|Name], [public123|Name], # [event123|Name] or a raw-URL bracket link [https://example.com|Title] _VK_LINK_RE = re.compile( r"\[(?:(?Pclub|id|public|event)(?P\d+)|(?Phttps?://[^\s\|\]]+))\|(?P[^\]]+)\]" ) def clean_vk_wiki_links(text: str, parse_mode: str = "html") -> str: """ Converts VK bracket markup into real links, escaping everything else so the result is always safe to send with parse_mode="HTML": - [club12345|Name] -> <a href="https://vk.com/club12345">Name</a> - [id12345|Name] -> <a href="https://vk.com/id12345">Name</a> - [https://example.com|Title] -> <a href="https://example.com">Title</a> Any text outside of recognized bracket markup is HTML-escaped, so stray "&", "<", ">" characters in real VK post text never break the parser. """ if parse_mode != "html": def _replace_plain(match: re.Match) -> str: title = match.group("title") if match.group("prefix"): target = f"https://vk.com/{match.group('prefix')}{match.group('obj_id')}" else: target = match.group("url") return f"[{title}]({target})" return _VK_LINK_RE.sub(_replace_plain, text) out: list[str] = [] last_end = 0 for match in _VK_LINK_RE.finditer(text): out.append(html.escape(text[last_end:match.start()])) title = html.escape(match.group("title")) if match.group("prefix"): href = f"https://vk.com/{match.group('prefix')}{match.group('obj_id')}" else: href = html.escape(match.group("url"), quote=True) out.append(f'<a href="{href}">{title}</a>') last_end = match.end() out.append(html.escape(text[last_end:])) return "".join(out) _ALLOWED_WRAPPER_TAGS = {"b", "strong", "i", "em", "u", "s", "code", "a"} class _SafeHTMLNormalizer(HTMLParser): """Normalizes admin-supplied header/footer/tags text: keeps a small safe subset of inline HTML tags (with href-scheme validation for <a>) and HTML-escapes everything else, instead of the previous all-or-nothing '"<" not in text' heuristic.""" def __init__(self) -> None: super().__init__(convert_charrefs=True) self.out: list[str] = [] self.open_tags: list[str] = [] def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None: if tag not in _ALLOWED_WRAPPER_TAGS: return if tag == "a": href = next((v for k, v in attrs if k == "href" and v), "") parsed = urlparse(href) if parsed.scheme not in ("http", "https") or not parsed.netloc: return self.out.append(f'<a href="{html.escape(href, quote=True)}">') else: self.out.append(f"<{tag}>") self.open_tags.append(tag) def handle_startendtag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None: if tag == "br": self.out.append("\n") def handle_endtag(self, tag: str) -> None: if tag == "br": self.out.append("\n") return if tag in self.open_tags: while self.open_tags: t = self.open_tags.pop() self.out.append(f"</{t}>") if t == tag: break def handle_data(self, data: str) -> None: self.out.append(html.escape(data)) def normalized(self) -> str: while self.open_tags: self.out.append(f"</{self.open_tags.pop()}>") return "".join(self.out) def normalize_wrapper_text(text: str, parse_mode: str = "html") -> str: """Safely prepares admin-configured header/footer/tags text for sending.""" text = str(text or "").strip() if not text: return "" if parse_mode != "html": return text parser = _SafeHTMLNormalizer() parser.feed(text) parser.close() return parser.normalized() def build_media_unavailable_note( link_only_items: list, parse_mode: str = "html", vk_url: Optional[str] = None ) -> str: """Builds a short note listing media that couldn't be attached (too big, too long, private, download failed, etc.) with a link to the original, so readers aren't left with no idea that media was omitted.""" if not link_only_items: return "" lines = [] for item in link_only_items: url = str(getattr(item, "original_url", "") or "").strip() media_type = str(getattr(item, "media_type", "медиа") or "медиа") if not url: continue if media_type == "video": target = vk_url or url label = "Смотреть видео в VK" else: target = url label = media_type if parse_mode == "html": lines.append(f'- <a href="{html.escape(target, quote=True)}">{html.escape(label)}</a>') else: lines.append(f"- {label}: {target}") if not lines: return "" header = "Не поместилось в пост:" if parse_mode == "html": header = f"<i>{html.escape(header)}</i>" return header + "\n" + "\n".join(lines) def build_oversized_video_note(items: list, parse_mode: str = "html") -> str: """Like build_media_unavailable_note, but for videos that were deliberately skipped for exceeding a platform's size limit (e.g. MAX's 250MB video cap) - friendlier tone since this isn't a failure, just a known platform limit.""" if not items: return "" lines = [] for item in items: url = str(getattr(item, "original_url", "") or "").strip() if not url: continue if parse_mode == "html": lines.append(f'- <a href="{html.escape(url, quote=True)}">видео по ссылке</a>') else: lines.append(f"- видео по ссылке: {url}") if not lines: return "" header = "Видео слишком большое для платформы, посмотреть можно здесь:" if parse_mode == "html": header = f"<i>{html.escape(header)}</i>" return header + "\n" + "\n".join(lines) def format_post_text( raw_text: str, *, parse_mode: str = "html", # "html" or "plain" header: Optional[str] = None, footer: Optional[str] = None, tags: Optional[str] = None, bold_first_line: bool = True, vk_url: Optional[str] = None, ) -> str: header = normalize_wrapper_text(settings.header_text if header is None else header, parse_mode) footer = normalize_wrapper_text(settings.footer_text if footer is None else footer, parse_mode) tags = normalize_wrapper_text(settings.common_tags if tags is None else tags, parse_mode) lines = clean_dividers(raw_text.splitlines()) title_idx: Optional[int] = None for idx, line in enumerate(lines): if line.strip(): title_idx = idx break formatted_lines: list[str] = [] for idx, line in enumerate(lines): cleaned_line = clean_vk_wiki_links(line, parse_mode=parse_mode) if idx == title_idx and bold_first_line and cleaned_line.strip(): if parse_mode == "html": formatted_lines.append(f"<b>{cleaned_line.strip()}</b>") else: formatted_lines.append(f"**{cleaned_line.strip()}**") # Blank line after header if next line is not empty if idx + 1 < len(lines) and lines[idx + 1].strip() != "": formatted_lines.append("") else: formatted_lines.append(cleaned_line) raw_body = "\n".join(formatted_lines) normalized_body = re.sub(r"\n{3,}", "\n\n", raw_body).strip() parts: list[str] = [] if header: parts.append(header) if normalized_body: parts.append(normalized_body) if footer: parts.append(footer) if tags: parts.append(tags) return "\n\n".join(part for part in parts if part).strip()